commit cc4326140be0e08693a1e9aef9a3a64e2fd75b30 Author: shumengya Date: Wed Mar 11 20:11:49 2026 +0800 shumengya mail@smyhub.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2236d97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.wrangler/ +node_modules/ +.env +.env.* +.dev.vars +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..7596b93 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# cf-ip-geo (Cloudflare Workers) + +一个基于 **Cloudflare Workers** 的轻量 HTTP API:返回访问者的 **公网 IP** 与 Cloudflare 边缘侧注入的 **地理/网络信息**(`request.cf`)。 + +> 公网 IP 优先取 `CF-Connecting-IP` 请求头;地理信息来自 `request.cf`(本地 dev 环境可能为空)。 + +## Endpoints + +- `GET /`:首页文档(附带一个 `GET /api?pretty=1` 的实时示例) +- `GET /api`:返回 JSON(支持 `?pretty=1`) +- `GET /ipv4`:返回 JSON(仅允许 IPv4 连接;否则 400,支持 `?pretty=1`) +- `GET /ipv6`:返回 JSON(仅允许 IPv6 连接;否则 400,支持 `?pretty=1`) + +> 所有 API 路径默认启用 CORS:`Access-Control-Allow-Origin: *` + +## Examples + +```bash +curl https:///api?pretty=1 +curl -4 https:///ipv4?pretty=1 +curl -6 https:///ipv6?pretty=1 +``` + +> 说明:是否走 IPv4/IPv6 由客户端网络与 DNS 决定(浏览器常用 Happy Eyeballs 策略),服务端无法把一次 IPv4 请求强制切到 IPv6;命令行测试请使用 `curl -6`。 + +## Response (/api) + +```json +{ + "ip": "203.0.113.10", + "ipVersion": "ipv4", + "userAgent": "...", + "geo": { + "country": "CN", + "region": "Beijing", + "regionCode": "BJ", + "city": "Beijing", + "postalCode": "", + "timezone": "Asia/Shanghai", + "continent": "AS", + "latitude": "39.90", + "longitude": "116.40" + }, + "network": { + "asn": 4134, + "asOrganization": "CHINANET", + "colo": "HKG", + "httpProtocol": "HTTP/2" + }, + "ts": "2026-01-27T12:34:56.000Z" +} +``` diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..84e4c28 --- /dev/null +++ b/worker.js @@ -0,0 +1,549 @@ +function isValidIPv4(address) { + const parts = address.split("."); + if (parts.length !== 4) return false; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return false; + const n = Number(part); + if (n < 0 || n > 255) return false; + if (part.length > 1 && part.startsWith("0")) return false; + } + return true; +} + +function isValidIPv6(address) { + if (typeof address !== "string") return false; + let addr = address.trim(); + if (addr.startsWith("[") && addr.endsWith("]")) addr = addr.slice(1, -1); + const percentIndex = addr.indexOf("%"); + if (percentIndex !== -1) addr = addr.slice(0, percentIndex); + if (addr.length === 0) return false; + if (!/^[0-9a-fA-F:.]+$/.test(addr)) return false; + + const hasEmbeddedIPv4 = addr.includes("."); + if (hasEmbeddedIPv4) { + const lastColon = addr.lastIndexOf(":"); + if (lastColon === -1) return false; + const tail = addr.slice(lastColon + 1); + if (!isValidIPv4(tail)) return false; + addr = `${addr.slice(0, lastColon)}:ipv4`; + } + + const isGroup = (g) => g === "ipv4" || /^[0-9a-fA-F]{1,4}$/.test(g); + const countGroups = (groups) => + groups.reduce((sum, g) => sum + (g === "ipv4" ? 2 : 1), 0); + + if (addr.includes("::")) { + if (addr.indexOf("::") !== addr.lastIndexOf("::")) return false; + const [left, right] = addr.split("::"); + const leftGroups = left ? left.split(":").filter(Boolean) : []; + const rightGroups = right ? right.split(":").filter(Boolean) : []; + if (!leftGroups.every(isGroup) || !rightGroups.every(isGroup)) return false; + const total = countGroups(leftGroups) + countGroups(rightGroups); + return total < 8; + } + + const groups = addr.split(":"); + if (groups.some((g) => g.length === 0)) return false; + if (!groups.every(isGroup)) return false; + const total = countGroups(groups); + return total === 8; +} + +function classifyIp(address) { + if (!address) return "unknown"; + if (isValidIPv4(address)) return "ipv4"; + if (isValidIPv6(address)) return "ipv6"; + return "unknown"; +} + +function getClientIp(req) { + return ( + req.headers.get("CF-Connecting-IP") || + (req.headers.get("X-Forwarded-For") || "").split(",")[0].trim() || + "" + ); +} + +const CONTINENT_ZH = { + AF: "非洲", AN: "南极洲", AS: "亚洲", EU: "欧洲", + NA: "北美洲", OC: "大洋洲", SA: "南美洲", +}; + +const COUNTRY_ZH = { + AD: "安道尔", AE: "阿联酋", AF: "阿富汗", AG: "安提瓜和巴布达", + AI: "安圭拉", AL: "阿尔巴尼亚", AM: "亚美尼亚", AO: "安哥拉", + AQ: "南极洲", AR: "阿根廷", AS: "美属萨摩亚", AT: "奥地利", + AU: "澳大利亚", AW: "阿鲁巴", AX: "奥兰群岛", AZ: "阿塞拜疆", + BA: "波黑", BB: "巴巴多斯", BD: "孟加拉国", BE: "比利时", + BF: "布基纳法索", BG: "保加利亚", BH: "巴林", BI: "布隆迪", + BJ: "贝宁", BL: "圣巴泰勒米", BM: "百慕大", BN: "文莱", + BO: "玻利维亚", BQ: "荷属加勒比", BR: "巴西", BS: "巴哈马", + BT: "不丹", BV: "布韦岛", BW: "博茨瓦纳", BY: "白俄罗斯", + BZ: "伯利兹", CA: "加拿大", CC: "科科斯群岛", CD: "刚果(金)", + CF: "中非", CG: "刚果(布)", CH: "瑞士", CI: "科特迪瓦", + CK: "库克群岛", CL: "智利", CM: "喀麦隆", CN: "中国", + CO: "哥伦比亚", CR: "哥斯达黎加", CU: "古巴", CV: "佛得角", + CW: "库拉索", CX: "圣诞岛", CY: "塞浦路斯", CZ: "捷克", + DE: "德国", DJ: "吉布提", DK: "丹麦", DM: "多米尼克", + DO: "多米尼加", DZ: "阿尔及利亚", EC: "厄瓜多尔", EE: "爱沙尼亚", + EG: "埃及", EH: "西撒哈拉", ER: "厄立特里亚", ES: "西班牙", + ET: "埃塞俄比亚", FI: "芬兰", FJ: "斐济", FK: "福克兰群岛", + FM: "密克罗尼西亚", FO: "法罗群岛", FR: "法国", GA: "加蓬", + GB: "英国", GD: "格林纳达", GE: "格鲁吉亚", GF: "法属圭亚那", + GG: "根西岛", GH: "加纳", GI: "直布罗陀", GL: "格陵兰", + GM: "冈比亚", GN: "几内亚", GP: "瓜德罗普", GQ: "赤道几内亚", + GR: "希腊", GS: "南乔治亚和南桑威奇群岛", GT: "危地马拉", GU: "关岛", + GW: "几内亚比绍", GY: "圭亚那", HK: "中国香港", HM: "赫德岛和麦克唐纳群岛", + HN: "洪都拉斯", HR: "克罗地亚", HT: "海地", HU: "匈牙利", + ID: "印度尼西亚", IE: "爱尔兰", IL: "以色列", IM: "马恩岛", + IN: "印度", IO: "英属印度洋领地", IQ: "伊拉克", IR: "伊朗", + IS: "冰岛", IT: "意大利", JE: "泽西岛", JM: "牙买加", + JO: "约旦", JP: "日本", KE: "肯尼亚", KG: "吉尔吉斯斯坦", + KH: "柬埔寨", KI: "基里巴斯", KM: "科摩罗", KN: "圣基茨和尼维斯", + KP: "朝鲜", KR: "韩国", KW: "科威特", KY: "开曼群岛", + KZ: "哈萨克斯坦", LA: "老挝", LB: "黎巴嫩", LC: "圣卢西亚", + LI: "列支敦士登", LK: "斯里兰卡", LR: "利比里亚", LS: "莱索托", + LT: "立陶宛", LU: "卢森堡", LV: "拉脱维亚", LY: "利比亚", + MA: "摩洛哥", MC: "摩纳哥", MD: "摩尔多瓦", ME: "黑山", + MF: "法属圣马丁", MG: "马达加斯加", MH: "马绍尔群岛", MK: "北马其顿", + ML: "马里", MM: "缅甸", MN: "蒙古", MO: "中国澳门", + MP: "北马里亚纳群岛", MQ: "马提尼克", MR: "毛里塔尼亚", MS: "蒙特塞拉特", + MT: "马耳他", MU: "毛里求斯", MV: "马尔代夫", MW: "马拉维", + MX: "墨西哥", MY: "马来西亚", MZ: "莫桑比克", NA: "纳米比亚", + NC: "新喀里多尼亚", NE: "尼日尔", NF: "诺福克岛", NG: "尼日利亚", + NI: "尼加拉瓜", NL: "荷兰", NO: "挪威", NP: "尼泊尔", + NR: "瑙鲁", NU: "纽埃", NZ: "新西兰", OM: "阿曼", + PA: "巴拿马", PE: "秘鲁", PF: "法属波利尼西亚", PG: "巴布亚新几内亚", + PH: "菲律宾", PK: "巴基斯坦", PL: "波兰", PM: "圣皮埃尔和密克隆", + PN: "皮特凯恩群岛", PR: "波多黎各", PS: "巴勒斯坦", PT: "葡萄牙", + PW: "帕劳", PY: "巴拉圭", QA: "卡塔尔", RE: "留尼汪", + RO: "罗马尼亚", RS: "塞尔维亚", RU: "俄罗斯", RW: "卢旺达", + SA: "沙特阿拉伯", SB: "所罗门群岛", SC: "塞舌尔", SD: "苏丹", + SE: "瑞典", SG: "新加坡", SH: "圣赫勒拿", SI: "斯洛文尼亚", + SJ: "斯瓦尔巴和扬马延", SK: "斯洛伐克", SL: "塞拉利昂", SM: "圣马力诺", + SN: "塞内加尔", SO: "索马里", SR: "苏里南", SS: "南苏丹", + ST: "圣多美和普林西比", SV: "萨尔瓦多", SX: "荷属圣马丁", SY: "叙利亚", + SZ: "斯威士兰", TC: "特克斯和凯科斯群岛", TD: "乍得", TF: "法属南部领地", + TG: "多哥", TH: "泰国", TJ: "塔吉克斯坦", TK: "托克劳", + TL: "东帝汶", TM: "土库曼斯坦", TN: "突尼斯", TO: "汤加", + TR: "土耳其", TT: "特立尼达和多巴哥", TV: "图瓦卢", TW: "中国台湾", + TZ: "坦桑尼亚", UA: "乌克兰", UG: "乌干达", UM: "美国本土外小岛屿", + US: "美国", UY: "乌拉圭", UZ: "乌兹别克斯坦", VA: "梵蒂冈", + VC: "圣文森特和格林纳丁斯", VE: "委内瑞拉", VG: "英属维尔京群岛", + VI: "美属维尔京群岛", VN: "越南", VU: "瓦努阿图", + WF: "瓦利斯和富图纳", WS: "萨摩亚", XK: "科索沃", YE: "也门", + YT: "马约特", ZA: "南非", ZM: "赞比亚", ZW: "津巴布韦", +}; + +const CN_REGION_ZH = { + AH: "安徽", BJ: "北京", CQ: "重庆", FJ: "福建", GD: "广东", + GS: "甘肃", GX: "广西", GZ: "贵州", HA: "河南", HB: "湖北", + HE: "河北", HI: "海南", HK: "香港", HL: "黑龙江", HN: "湖南", + JL: "吉林", JS: "江苏", JX: "江西", LN: "辽宁", MO: "澳门", + NM: "内蒙古", NX: "宁夏", QH: "青海", SC: "四川", SD: "山东", + SH: "上海", SN: "陕西", SX: "山西", TJ: "天津", TW: "台湾", + XJ: "新疆", XZ: "西藏", YN: "云南", ZJ: "浙江", +}; + +const CN_CITY_ZH = { + "Beijing": "北京", "Shanghai": "上海", "Tianjin": "天津", "Chongqing": "重庆", + "Shijiazhuang": "石家庄", "Tangshan": "唐山", "Qinhuangdao": "秦皇岛", + "Handan": "邯郸", "Xingtai": "邢台", "Baoding": "保定", + "Zhangjiakou": "张家口", "Chengde": "承德", "Cangzhou": "沧州", + "Langfang": "廊坊", "Hengshui": "衡水", + "Taiyuan": "太原", "Datong": "大同", "Yangquan": "阳泉", + "Changzhi": "长治", "Jincheng": "晋城", "Shuozhou": "朔州", + "Jinzhong": "晋中", "Yuncheng": "运城", "Xinzhou": "忻州", + "Linfen": "临汾", "Lüliang": "吕梁", "Lvliang": "吕梁", "Luliang": "吕梁", + "Hohhot": "呼和浩特", "Baotou": "包头", "Wuhai": "乌海", + "Chifeng": "赤峰", "Tongliao": "通辽", "Ordos": "鄂尔多斯", + "Hulunbuir": "呼伦贝尔", "Hulun Buir": "呼伦贝尔", + "Bayannur": "巴彦淖尔", "Ulanqab": "乌兰察布", "Wulanchabu": "乌兰察布", + "Hinggan": "兴安", "Xilingol": "锡林郭勒", "Xilin Gol": "锡林郭勒", + "Alxa": "阿拉善", "Alashan": "阿拉善", + "Shenyang": "沈阳", "Dalian": "大连", "Anshan": "鞍山", + "Fushun": "抚顺", "Benxi": "本溪", "Dandong": "丹东", + "Jinzhou": "锦州", "Yingkou": "营口", "Fuxin": "阜新", + "Liaoyang": "辽阳", "Panjin": "盘锦", "Tieling": "铁岭", + "Chaoyang": "朝阳", "Huludao": "葫芦岛", + "Changchun": "长春", "Jilin": "吉林", "Siping": "四平", + "Liaoyuan": "辽源", "Tonghua": "通化", "Baishan": "白山", + "Songyuan": "松原", "Baicheng": "白城", "Yanbian": "延边", + "Harbin": "哈尔滨", "Qiqihar": "齐齐哈尔", + "Jixi": "鸡西", "Hegang": "鹤岗", "Shuangyashan": "双鸭山", + "Daqing": "大庆", "Jiamusi": "佳木斯", "Qitaihe": "七台河", + "Mudanjiang": "牡丹江", "Heihe": "黑河", "Suihua": "绥化", + "Daxing'anling": "大兴安岭", "Daxinganling": "大兴安岭", + "Nanjing": "南京", "Wuxi": "无锡", "Xuzhou": "徐州", + "Changzhou": "常州", "Nantong": "南通", "Lianyungang": "连云港", + "Huai'an": "淮安", "Huaian": "淮安", "Yancheng": "盐城", + "Yangzhou": "扬州", "Zhenjiang": "镇江", "Suqian": "宿迁", + "Hangzhou": "杭州", "Ningbo": "宁波", "Wenzhou": "温州", + "Jiaxing": "嘉兴", "Huzhou": "湖州", "Shaoxing": "绍兴", + "Jinhua": "金华", "Quzhou": "衢州", "Zhoushan": "舟山", "Lishui": "丽水", + "Hefei": "合肥", "Wuhu": "芜湖", "Bengbu": "蚌埠", + "Huainan": "淮南", "Ma'anshan": "马鞍山", "Maanshan": "马鞍山", + "Huaibei": "淮北", "Tongling": "铜陵", "Anqing": "安庆", + "Huangshan": "黄山", "Chuzhou": "滁州", "Fuyang": "阜阳", + "Lu'an": "六安", "Luan": "六安", "Bozhou": "亳州", + "Chizhou": "池州", "Xuancheng": "宣城", + "Xiamen": "厦门", "Putian": "莆田", "Sanming": "三明", + "Quanzhou": "泉州", "Zhangzhou": "漳州", "Nanping": "南平", + "Longyan": "龙岩", "Ningde": "宁德", + "Nanchang": "南昌", "Jingdezhen": "景德镇", "Pingxiang": "萍乡", + "Jiujiang": "九江", "Xinyu": "新余", "Yingtan": "鹰潭", + "Ganzhou": "赣州", "Ji'an": "吉安", "Jian": "吉安", "Shangrao": "上饶", + "Jinan": "济南", "Qingdao": "青岛", "Zibo": "淄博", + "Zaozhuang": "枣庄", "Dongying": "东营", "Yantai": "烟台", + "Weifang": "潍坊", "Jining": "济宁", "Tai'an": "泰安", "Taian": "泰安", + "Weihai": "威海", "Rizhao": "日照", "Linyi": "临沂", + "Dezhou": "德州", "Liaocheng": "聊城", "Binzhou": "滨州", "Heze": "菏泽", + "Zhengzhou": "郑州", "Kaifeng": "开封", "Luoyang": "洛阳", + "Pingdingshan": "平顶山", "Anyang": "安阳", "Hebi": "鹤壁", + "Xinxiang": "新乡", "Jiaozuo": "焦作", "Puyang": "濮阳", + "Xuchang": "许昌", "Luohe": "漯河", "Sanmenxia": "三门峡", + "Nanyang": "南阳", "Shangqiu": "商丘", "Xinyang": "信阳", + "Zhoukou": "周口", "Zhumadian": "驻马店", "Jiyuan": "济源", + "Wuhan": "武汉", "Huangshi": "黄石", "Shiyan": "十堰", + "Yichang": "宜昌", "Xiangyang": "襄阳", "Ezhou": "鄂州", + "Jingmen": "荆门", "Xiaogan": "孝感", "Jingzhou": "荆州", + "Huanggang": "黄冈", "Xianning": "咸宁", "Suizhou": "随州", + "Enshi": "恩施", "Xiantao": "仙桃", "Qianjiang": "潜江", + "Tianmen": "天门", "Shennongjia": "神农架", + "Changsha": "长沙", "Zhuzhou": "株洲", "Xiangtan": "湘潭", + "Hengyang": "衡阳", "Shaoyang": "邵阳", "Yueyang": "岳阳", + "Changde": "常德", "Zhangjiajie": "张家界", "Yiyang": "益阳", + "Chenzhou": "郴州", "Yongzhou": "永州", "Huaihua": "怀化", + "Loudi": "娄底", "Xiangxi": "湘西", + "Guangzhou": "广州", "Shaoguan": "韶关", "Shenzhen": "深圳", + "Zhuhai": "珠海", "Shantou": "汕头", "Foshan": "佛山", + "Jiangmen": "江门", "Zhanjiang": "湛江", "Maoming": "茂名", + "Zhaoqing": "肇庆", "Huizhou": "惠州", "Meizhou": "梅州", + "Shanwei": "汕尾", "Heyuan": "河源", "Yangjiang": "阳江", + "Qingyuan": "清远", "Dongguan": "东莞", "Zhongshan": "中山", + "Chaozhou": "潮州", "Jieyang": "揭阳", "Yunfu": "云浮", + "Nanning": "南宁", "Liuzhou": "柳州", "Guilin": "桂林", + "Wuzhou": "梧州", "Beihai": "北海", "Fangchenggang": "防城港", + "Qinzhou": "钦州", "Guigang": "贵港", "Baise": "百色", "Bose": "百色", + "Hechi": "河池", "Laibin": "来宾", "Chongzuo": "崇左", "Hezhou": "贺州", + "Haikou": "海口", "Sanya": "三亚", "Sansha": "三沙", + "Danzhou": "儋州", "Wuzhishan": "五指山", "Wanning": "万宁", + "Dongfang": "东方", "Qionghai": "琼海", "Wenchang": "文昌", + "Chengdu": "成都", "Zigong": "自贡", "Panzhihua": "攀枝花", + "Luzhou": "泸州", "Deyang": "德阳", "Mianyang": "绵阳", + "Guangyuan": "广元", "Suining": "遂宁", "Neijiang": "内江", + "Leshan": "乐山", "Nanchong": "南充", "Meishan": "眉山", + "Yibin": "宜宾", "Guang'an": "广安", "Guangan": "广安", + "Dazhou": "达州", "Ya'an": "雅安", "Yaan": "雅安", + "Bazhong": "巴中", "Ziyang": "资阳", + "Aba": "阿坝", "Ngawa": "阿坝", "Garze": "甘孜", "Ganzi": "甘孜", + "Liangshan": "凉山", + "Guiyang": "贵阳", "Liupanshui": "六盘水", "Zunyi": "遵义", + "Anshun": "安顺", "Bijie": "毕节", "Tongren": "铜仁", + "Qianxinan": "黔西南", "Qiandongnan": "黔东南", "Qiannan": "黔南", + "Kunming": "昆明", "Qujing": "曲靖", "Yuxi": "玉溪", + "Baoshan": "保山", "Zhaotong": "昭通", "Lijiang": "丽江", + "Pu'er": "普洱", "Puer": "普洱", "Lincang": "临沧", + "Chuxiong": "楚雄", "Honghe": "红河", "Wenshan": "文山", + "Xishuangbanna": "西双版纳", "Dali": "大理", "Dehong": "德宏", + "Nujiang": "怒江", "Diqing": "迪庆", "Deqen": "迪庆", + "Lhasa": "拉萨", "Chamdo": "昌都", "Changdu": "昌都", + "Shannan": "山南", "Lhoka": "山南", + "Shigatse": "日喀则", "Xigaze": "日喀则", + "Nagqu": "那曲", "Naqu": "那曲", + "Ngari": "阿里", "Ali": "阿里", + "Nyingchi": "林芝", "Linzhi": "林芝", + "Xi'an": "西安", "Xian": "西安", "Tongchuan": "铜川", + "Baoji": "宝鸡", "Xianyang": "咸阳", "Weinan": "渭南", + "Yan'an": "延安", "Yanan": "延安", "Hanzhong": "汉中", + "Ankang": "安康", "Shangluo": "商洛", + "Lanzhou": "兰州", "Jiayuguan": "嘉峪关", "Jinchang": "金昌", + "Baiyin": "白银", "Tianshui": "天水", "Wuwei": "武威", + "Zhangye": "张掖", "Pingliang": "平凉", "Jiuquan": "酒泉", + "Qingyang": "庆阳", "Dingxi": "定西", "Longnan": "陇南", + "Linxia": "临夏", "Gannan": "甘南", + "Xining": "西宁", "Haidong": "海东", "Haibei": "海北", + "Huangnan": "黄南", "Guoluo": "果洛", "Golog": "果洛", + "Yushu": "玉树", "Haixi": "海西", + "Yinchuan": "银川", "Shizuishan": "石嘴山", "Wuzhong": "吴忠", + "Guyuan": "固原", "Zhongwei": "中卫", + "Urumqi": "乌鲁木齐", "Karamay": "克拉玛依", + "Turpan": "吐鲁番", "Turfan": "吐鲁番", + "Hami": "哈密", "Changji": "昌吉", + "Bortala": "博尔塔拉", "Bole": "博乐", + "Bayingol": "巴音郭楞", "Bayingolin": "巴音郭楞", + "Aksu": "阿克苏", "Kizilsu": "克孜勒苏", + "Kashgar": "喀什", "Hotan": "和田", + "Ili": "伊犁", "Yili": "伊犁", + "Tacheng": "塔城", "Altay": "阿勒泰", + "Shihezi": "石河子", "Aral": "阿拉尔", "Alar": "阿拉尔", + "Tumxuk": "图木舒克", "Tumushuke": "图木舒克", + "Wujiaqu": "五家渠", "Beitun": "北屯", + "Hong Kong": "香港", "Macau": "澳门", "Macao": "澳门", + "Taipei": "台北", "New Taipei": "新北", "Kaohsiung": "高雄", + "Taichung": "台中", "Tainan": "台南", "Hsinchu": "新竹", + "Keelung": "基隆", "Chiayi": "嘉义", "Taoyuan": "桃园", + "Pingtung": "屏东", "Changhua": "彰化", "Hualien": "花莲", + "Yilan": "宜兰", "Nantou": "南投", "Miaoli": "苗栗", + "Yunlin": "云林", "Taitung": "台东", "Penghu": "澎湖", "Kinmen": "金门", +}; + +const CN_CITY_DISAMBIG = { + "Taizhou": { JS: "泰州", ZJ: "台州" }, + "Fuzhou": { FJ: "福州", JX: "抚州" }, + "Yichun": { HL: "伊春", JX: "宜春" }, + "Yulin": { GX: "玉林", SN: "榆林" }, + "Suzhou": { JS: "苏州", AH: "宿州" }, +}; + +function getRegionName(country, region, regionCode) { + if (country === "CN" && regionCode) return CN_REGION_ZH[regionCode] || region || ""; + return region || ""; +} + +function getCityName(country, city, regionCode) { + if (country !== "CN" && country !== "HK" && country !== "MO" && country !== "TW") return city || ""; + const disambig = CN_CITY_DISAMBIG[city]; + if (disambig && regionCode && disambig[regionCode]) return disambig[regionCode]; + return CN_CITY_ZH[city] || city || ""; +} + +function jsonResponse(payload, reqUrl, { status = 200, headers = {} } = {}) { + const pretty = reqUrl.searchParams.get("pretty"); + const body = pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload); + return new Response(body, { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + ...headers, + }, + }); +} + +export default { + async fetch(request) { + const url = new URL(request.url); + const path = url.pathname; + + // --- CORS(给 API 路径用) --- + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + }; + + // 预检 + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders }); + } + + // 只提供 GET + if (request.method !== "GET") { + return new Response("Method Not Allowed", { status: 405 }); + } + + // 取公网 IP(优先 CF-Connecting-IP) + const ip = getClientIp(request); + const ipVersion = classifyIp(ip); + + // Cloudflare 地理/网络信息(在边缘注入;本地 dev 可能为空) + const cf = request.cf || {}; + + const apiPayload = { + ip, + ipVersion, + userAgent: request.headers.get("User-Agent") || "", + // 常见 cf 字段(不同账号/计划/场景可用字段会有差异) + geo: { + country: cf.country || "", + countryName: COUNTRY_ZH[cf.country] || cf.country || "", + region: cf.region || "", + regionName: getRegionName(cf.country, cf.region, cf.regionCode), + regionCode: cf.regionCode || "", + city: cf.city || "", + cityName: getCityName(cf.country, cf.city, cf.regionCode), + postalCode: cf.postalCode || "", + timezone: cf.timezone || "", + continent: cf.continent || "", + continentName: CONTINENT_ZH[cf.continent] || cf.continent || "", + latitude: cf.latitude || "", + longitude: cf.longitude || "", + }, + network: { + asn: cf.asn || "", + asOrganization: cf.asOrganization || "", + colo: cf.colo || "", + httpProtocol: cf.httpProtocol || "", + }, + ts: new Date().toISOString(), + // 如果你想“全量返回”,可把 cf 全部带回去(注意体积) + // cf, + }; + + // API:/api + if (path === "/api") { + return jsonResponse(apiPayload, url, { headers: { ...corsHeaders } }); + } + + // 仅返回 IPv4:/ipv4 + if (path === "/ipv4") { + const expected = "ipv4"; + if (!ip || ipVersion === "unknown") { + return jsonResponse( + { + ok: false, + expected, + got: ipVersion, + ip, + error: "IP not found", + ts: new Date().toISOString(), + }, + url, + { status: 400, headers: { ...corsHeaders } }, + ); + } + if (ipVersion !== expected) { + return jsonResponse( + { + ok: false, + expected, + got: ipVersion, + ip, + error: "Expected IPv4, but request reached Cloudflare over a non-IPv4 connection.", + hint: + "服务器无法强制切换 IP 协议栈;请在客户端侧使用 IPv4 访问(例如 curl -4),或检查网络/DNS 的优先级。", + ts: new Date().toISOString(), + }, + url, + { status: 400, headers: { ...corsHeaders } }, + ); + } + return jsonResponse( + { ok: true, ip, ipVersion, ts: new Date().toISOString() }, + url, + { headers: { ...corsHeaders } }, + ); + } + + // 仅返回 IPv6:/ipv6 + if (path === "/ipv6") { + const expected = "ipv6"; + if (!ip || ipVersion === "unknown") { + return jsonResponse( + { + ok: false, + expected, + got: ipVersion, + ip, + error: "IP not found", + ts: new Date().toISOString(), + }, + url, + { status: 400, headers: { ...corsHeaders } }, + ); + } + if (ipVersion !== expected) { + return jsonResponse( + { + ok: false, + expected, + got: ipVersion, + ip, + error: "Expected IPv6, but request reached Cloudflare over IPv4.", + hint: [ + "服务器端无法把一次 IPv4 请求“改成”IPv6;是否走 v4/v6 由客户端网络与 DNS 决定(Happy Eyeballs)。", + `命令行可用:curl -6 ${url.origin}/ipv6`, + "若你需要“强制 IPv6”,建议绑定一个仅 AAAA 解析的域名/子域名到本 Worker,再用该域名访问 /ipv6。", + ], + ts: new Date().toISOString(), + }, + url, + { status: 400, headers: { ...corsHeaders } }, + ); + } + return jsonResponse( + { ok: true, ip, ipVersion, ts: new Date().toISOString() }, + url, + { headers: { ...corsHeaders } }, + ); + } + + // 网页:/ + if (path === "/" || path === "/index.html") { + const html = ` + + + + + cf-ip-geo + + + +

cf-ip-geo

+

Cloudflare Workers: 根据访问者 IP 返回地理/网络信息(数据来自 request.cfCF-Connecting-IP)。

+ +
+

HTTP API

+
    +
  • GET /api:返回 JSON(支持 ?pretty=1)。
  • +
  • GET /ipv4:返回 JSON(仅允许 IPv4 连接;否则 400)。
  • +
  • GET /ipv6:返回 JSON(仅允许 IPv6 连接;否则 400)。
  • +
+

API 默认允许跨域(Access-Control-Allow-Origin: *)。

+

提示:浏览器/系统会自动选择 IPv4/IPv6(可能优先走 IPv4)。如需强制测试 IPv6,请用 curl -6 或使用仅 AAAA 解析的域名访问。

+ +

Examples

+
curl ${url.origin}/api?pretty=1
+curl -4 ${url.origin}/ipv4?pretty=1
+curl -6 ${url.origin}/ipv6?pretty=1
+
+ +
+ Live: GET /api?pretty=1 +
Loading…
+
+ + + +`; + return new Response(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + } + + // 其他路径:404 + return new Response("Not Found", { status: 404 }); + }, +}; diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..eeec19b --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,3 @@ +name = "cf-ip-geo" +main = "worker.js" +compatibility_date = "2026-03-05"