├── README.md └── cloudflare-partner-cli.py /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Partner CLI 2 | 3 | This is a CLI program that let you set CNAME to use Cloudflare using the partner program. 4 | 5 | Both Python2.x and Python3.x is supported. No extra library is needed. 6 | 7 | To use Chinese menu, set environment variable `LANG` to use **UTF-8** (for example, **zh_CN.UTF-8**). 8 | 9 | ### Usage 10 | 11 | 1. Apply for partner program at https://www.cloudflare.com/partners/. 12 | 2. Clone this repository or [download script](https://github.com/fffonion/cloudflare-partner-cli/raw/master/cloudflare-partner-cli.py). 13 | 3. Run `python ./cloudflare-partner-cli.py`. 14 | 4. Enter your `host_key`. You can get it [here](https://partners.cloudflare.com/api-management). 15 | 5. Enter the account you use to manage domains (your personal account, not partner login account). User key is stored in `.cfhost`. 16 | 6. Follow the instructions on screen. 17 | 18 | ### Note 19 | 20 | - Value of `resolve_to` has to be DNS record (for example: **google.com**) instead of IP address. 21 | - You may still need to wait some time after running `ssl_verification`. 22 | 23 | # Cloudflare Partner CLI 24 | 25 | 使用Cloudflare partner功能用CNAME方式接入cloudflare。 26 | 27 | 你可以使用Python2.x或者Python3.x。无需安装任何依赖。 28 | 29 | 如需使用中文菜单,请将环境变量的`LANG`设置为使用**UTF-8** (比如**zh_CN.UTF-8**)。 30 | 31 | ### 使用方法 32 | 33 | 1. 申请Cloudflare partner计划 https://www.cloudflare.com/partners/ 。 34 | 2. clone本项目或者[直接下载脚本](https://github.com/fffonion/cloudflare-partner-cli/raw/master/cloudflare-partner-cli.py)。 35 | 3. 运行 `python ./cloudflare-partner-cli.py`。 36 | 4. 输入 `host_key`。可以从[这里](https://partners.cloudflare.com/api-management)获得。 37 | 5. 输入要用来管理域名的账号 (你的个人账号,不是partner账号)。账户信息保存在`.cfhost`文件中。 38 | 6. 按照屏幕提示操作。 39 | 40 | ### 注意 41 | 42 | - `源站地址`必须为DNS记录,如**google.com**,不能填写IP地址。 43 | - 域名生效且运行`开通SSL`之后,仍需要等一段时间SSL证书才会生效 44 | -------------------------------------------------------------------------------- /cloudflare-partner-cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #coding: utf-8 3 | import os 4 | import json 5 | import sys 6 | PY3K = sys.version_info[0] == 3 7 | if not PY3K: 8 | import urllib2 9 | from urllib import urlencode 10 | else: 11 | import urllib.request as urllib2 12 | from urllib.parse import urlencode 13 | raw_input = input 14 | _map = map 15 | map = lambda *a, **k: list(_map(*a, **k)) 16 | import locale 17 | LOCALE = locale.getdefaultlocale() 18 | 19 | HOSTKEY = None # HOSTKEY_ANCHOR 20 | 21 | CFARG = { 22 | 'user_auth': ("cloudflare_email", "cloudflare_pass"), 23 | 'zone_set': ("zone_name", "subdomains", "resolve_to"), 24 | 'zone_delete': ("zone_name",), 25 | 'zone_list': (), 26 | 'zone_lookup': ("zone_name",), 27 | 'add_subdomain':("zone_name", "subdomains", "resolve_to"), 28 | 'delete_subdomain': ("zone_name", "subdomains"), 29 | 'ssl_verfication': ("zone",), 30 | 'host_key_regen': (), 31 | } 32 | CFHOST_FILE = ".cfhost" 33 | 34 | I18N = { 35 | "zone_list": "显示所有接入的域名", 36 | "zone_set": "接入域名", 37 | "zone_lookup": "显示DNS记录", 38 | "zone_delete": "删除接入的域名", 39 | "host_key_regen": "重新生成host key", 40 | "user_auth": "登录", 41 | "add_subdomain": "添加/修改DNS记录", 42 | "delete_subdomain": "删除DNS记录", 43 | "logout": "退出当前帐号", 44 | "ssl_verfication": "开通SSL", 45 | "zone_name": "根域名", 46 | "resolve_to": "源站地址", 47 | "subdomains": "子域名", 48 | "cloudflare_email": "邮箱", 49 | "cloudflare_pass": "密码", 50 | "zone": "根域名", 51 | "subdomain": "子域名", 52 | "Zone": "根域名", 53 | "User": "用户", 54 | "Subdomain": "子域名", 55 | "Resolve to": "源站地址", 56 | "Content": "内容", 57 | "Login as %s": "%s 已登录", 58 | "vetting": "审批中", 59 | "validating": "等待验证", 60 | "ready": "已启用", 61 | "none": "未知", 62 | "Select your action:": "选择所需的操作,输入数字:", 63 | "Missing required arg \"%s\". (act:%s)": "缺少参数 \"%s\". (act:%s)", 64 | "Login failed, msg: %s": "登录失败: %s", 65 | "Success! Please set CNAME record of %s to %s": "设置成功! 请将%s的CNAME记录设置为%s", 66 | "Domain %s has been removed from partner": "域名%s已取消接入", 67 | "%s (act: %s)": "报错: %s (act: %s) ", 68 | "Please enter your Cloudflare partner hostkey (https://partners.cloudflare.com/api-management)> ": 69 | "请输入 Cloudflare hostkey (https://partners.cloudflare.com/api-management)> ", 70 | "SSL status: %s": "SSL状态: %s", 71 | "Please login first. (uri: %s)": "请先登录. (uri: %s)", 72 | "Please login first. (act: %s)": "请先登录. (act: %s)", 73 | "No zone found matching %s. Please use zone_set first.": "账户中不存在域名%s, 请先添加域名", 74 | "SSL for domain %s has already been activated.": "域名%s已开通SSL, 无需操作", 75 | "??? %s": "喵喵喵? %s", 76 | "Please set CNAME record of %s to %s and run this option again after record become effective": 77 | "请将%s的CNAME记录设置为%s, 然后在解析生效后再运行一次\"开通SSL\"", 78 | "Please create a file with the following content and run this option again after record become effective:": 79 | "请新建文件使下列URL能访问到指定的内容,然后再运行一次\"开通SSL\":", 80 | "Can't delete root record": "不能删除根域名", 81 | "Record %s is deleted": "DNS记录%s已删除", 82 | "Record %s is not found in zone %s": "记录%s不存在于域名%s中", 83 | "If you want to activate SSL, please set CNAME record of %s to %s and " \ 84 | "run this \"ssl_verfication\" after record become effective": 85 | "如果需要启用SSL, 请将%s的CNAME记录设置为%s, 然后在解析生效后运行一次\"开通SSL\"", 86 | "Zone %s not exists or is not under user %s": "域名%s不存在,或者不属于用户%s", 87 | "Host key has been changed to %s": "Hostkey已更新为 %s", 88 | } 89 | 90 | def i18n(s): 91 | _ = I18N[s] if LOCALE[1] and LOCALE[1].lower() in ("utf-8", "cp936") and s in I18N else s 92 | if LOCALE[1].lower() == "cp936" and not PY3K: # windows with simplified Chinese locale 93 | return _.decode('utf-8').encode('gb18030') 94 | return _ 95 | 96 | def log(fmt, arg = (), level = "INFO"): 97 | if PY3K: 98 | if not (isinstance(arg, list) or isinstance(arg, tuple)): 99 | arg = (arg, ) 100 | arg = tuple([a.decode('ascii') if isinstance(a, bytes) else a for a in arg]) 101 | print("%-4s - %s" % (level, i18n(fmt) % arg)) 102 | 103 | def catch_err(func): 104 | def _(instance, j, *arg): 105 | if j['result'] == 'error': 106 | log("%s (act: %s)", (j['msg'].encode('utf-8'), j['request']['act'].encode('utf-8')), "ERR") 107 | return 108 | return func(instance, j, *arg) 109 | return _ 110 | 111 | class CF(object): 112 | def __init__(self): 113 | self.user_email = None 114 | self.user_key = None 115 | self.user_api_key = None 116 | if os.path.exists(CFHOST_FILE): 117 | r = open(CFHOST_FILE).read() 118 | _ = r.split(",") 119 | if len(_) != 3: 120 | try: 121 | os.remove(CFHOST_FILE) 122 | except: 123 | pass 124 | else: 125 | _ = map(lambda x:x.strip(), _) 126 | self.user_email, self.user_key, self.user_api_key = _ 127 | log("Login as %s", self.user_email) 128 | 129 | def _hostapi(self, act, extra={}): 130 | if not self.user_key and act not in ("user_auth", "host_key_regen"): 131 | log("Please login first. (act: %s)", act, "ERR") 132 | return 133 | payload = { 134 | "act": act, 135 | "host_key": HOSTKEY, 136 | } 137 | if act not in ("user_auth", "host_key_regen"): 138 | payload.update({"user_key": self.user_key}) 139 | if extra: 140 | payload.update(extra) 141 | if act in CFARG: 142 | for k in CFARG[act]: 143 | if k not in payload: 144 | log("Missing required arg \"%s\". (act:%s)", (k, act), "ERR") 145 | return 146 | if PY3K: 147 | payload = urlencode(payload).encode('ascii') 148 | else: 149 | payload = urlencode(payload) 150 | req = urllib2.Request("https://api.cloudflare.com/host-gw.html", payload) 151 | r = urllib2.urlopen(req).read() 152 | if PY3K and isinstance(r, bytes): 153 | r = r.decode('ascii') 154 | return json.loads(r) 155 | 156 | def _userapi(self, uri, method="GET", extra={}): 157 | if not self.user_api_key: 158 | log("Please login first. (uri: %s)", uri, "ERR") 159 | return 160 | headers = { 161 | 'X-Auth-Email': self.user_email, 162 | 'X-Auth-Key': self.user_api_key, 163 | 'Content-Type': "application/json", 164 | } 165 | if extra: 166 | headers.update(extra) 167 | opener = urllib2.build_opener(urllib2.HTTPHandler) 168 | req = urllib2.Request("https://api.cloudflare.com/client/v4%s" % uri) 169 | for k, v in headers.items(): 170 | req.add_header(k, v) 171 | req.get_method = lambda: method 172 | r = opener.open(req).read() 173 | if PY3K and isinstance(r, bytes): 174 | r = r.decode('ascii') 175 | return json.loads(r) 176 | 177 | return json.loads(r.read()) 178 | 179 | def user_auth(self, arg): 180 | ret = self._hostapi("user_auth", arg) 181 | if not ret: 182 | return False 183 | if 'response' not in ret or 'user_key' not in ret['response']: 184 | log("Login failed, msg: %s", ret['msg'].encode('utf-8'), "ERR") 185 | return False 186 | log("Login as %s", arg['cloudflare_email']) 187 | self.user_key = ret['response']['user_key'] 188 | self.user_api_key = ret['response']['user_api_key'] 189 | if not PY3K: 190 | self.user_email = arg['cloudflare_email'] 191 | self.user_key = self.user_key.encode('utf-8') 192 | self.user_api_key = self.user_api_key.encode('utf-8') 193 | open(CFHOST_FILE, "w").write("%s,%s,%s" % (self.user_email, self.user_key, self.user_api_key)) 194 | return True 195 | 196 | def logout(self, *arg): 197 | try: 198 | os.remove(CFHOST_FILE) 199 | except: 200 | pass 201 | os._exit(0) 202 | 203 | def ssl_verfication(self, arg): 204 | r = self._userapi("/zones?name=%s&match=all" % arg['zone']) 205 | if len(r['result']) < 1: 206 | log("No zone found matching %s. Please use zone_set first.", arg['zone'], "ERR") 207 | return 208 | zone_id = r['result'][0]['id'] 209 | r = self._userapi("/zones/%s/ssl/verification?retry=true" % zone_id) 210 | if len(r['result']) < 1: 211 | log("??? %s", r, "ERR") 212 | return 213 | if r['result'][0]['certificate_status'] == "active": 214 | log("SSL for domain %s has already been activated.", arg['zone']) 215 | return True 216 | verification_info = r['result'][0]['verification_info'] 217 | if 'record_name' in verification_info: # DNS verification 218 | log("Please set CNAME record of %s to %s and run this option again after record become effective", ( 219 | verification_info['record_name'].encode('utf-8'), verification_info['record_target'].encode('utf-8'))) 220 | else: # HTTP verification 221 | log("Please create a file with the following content and run this option again after record become effective:") 222 | print("%-90s%s" % (i18n("URL"), i18n("Content"))) 223 | print("-" * 80) 224 | print("\n".join(map(lambda x: "%-90s%s" % ( 225 | x['verification_info']['http_url'], 226 | x['verification_info']['http_body']), 227 | r['result']))) 228 | 229 | def add_subdomain(self, arg): 230 | r = self._hostapi("zone_lookup", {"zone_name": arg['zone_name']}) 231 | if 'hosted_cnames' not in r['response'] or not r['response']['hosted_cnames']: 232 | log("No zone found matching %s. Please use zone_set first.", arg['zone_name'], "ERR") 233 | return 234 | hosted = r['response']['hosted_cnames'] 235 | # concat a real subdomain 236 | if arg['subdomains'] == "@": 237 | subdomain = arg['zone_name'] 238 | elif arg['subdomains'].lower().endswith(arg['zone_name'].lower()): 239 | subdomain = arg['subdomains'] 240 | else: 241 | subdomain = "%s.%s" % (arg['subdomains'], arg['zone_name']) 242 | hosted[subdomain] = arg['resolve_to'] 243 | # make the subdomain to @ to avoid it being changed 244 | arg['resolve_to'] = hosted[arg['zone_name']] 245 | arg['subdomains'] = "@,%s" % (",".join(["%s:%s" % (k, v) for k, v in hosted.items() if k != arg['zone_name']])) 246 | r = self._hostapi("zone_set", arg) 247 | self._zone_set(r, subdomain) 248 | 249 | def delete_subdomain(self, arg): 250 | if arg['subdomains'] == "@": 251 | log("Can't delete root record", (), "ERR") 252 | return 253 | r = self._hostapi("zone_lookup", {"zone_name": arg['zone_name']}) 254 | if 'hosted_cnames' not in r['response'] or not r['response']['hosted_cnames']: 255 | log("No zone found matching %s. Please use zone_set first.", arg['zone_name'], "ERR") 256 | return 257 | hosted = r['response']['hosted_cnames'] 258 | # concat a real subdomain 259 | if arg['subdomains'] == "@": 260 | subdomain = arg['zone_name'] 261 | elif arg['subdomains'].lower().endswith(arg['zone_name'].lower()): 262 | subdomain = arg['subdomains'] 263 | else: 264 | subdomain = "%s.%s" % (arg['subdomains'], arg['zone_name']) 265 | if subdomain not in hosted: 266 | log("Record %s is not found in zone %s", (subdomain, arg['zone_name'])) 267 | return 268 | # make the subdomain to @ to avoid it being changed 269 | arg['resolve_to'] = hosted[arg['zone_name']] 270 | arg['subdomains'] = "@,%s" % (",".join(["%s:%s" % (k, v) for k, v in hosted.items() if k != arg['zone_name'] and k != subdomain])) 271 | r = self._hostapi("zone_set", arg) 272 | log("Record %s is deleted", subdomain) 273 | 274 | @catch_err 275 | def _zone_list(self, j): 276 | print("%-24s%-24s" % (i18n("Zone"), i18n("User"))) 277 | print("-" * 80) 278 | for z in j['response']: 279 | print("%-24s%-24s" % (z['zone_name'], z['user_email'])) 280 | 281 | @catch_err 282 | def _zone_set(self, j, resolve=None): 283 | if 'forward_tos' in j['response']: 284 | resolve = resolve if resolve else list(j['response']['forward_tos'].keys())[0] 285 | cname = j['response']['forward_tos'][resolve] 286 | log("Success! Please set CNAME record of %s to %s", (resolve.encode('utf-8'), cname.encode('utf-8'))) 287 | 288 | @catch_err 289 | def _zone_delete(self, j): 290 | log("Domain %s has been removed from partner", j['request']['zone_name'].encode('utf-8')) 291 | 292 | @catch_err 293 | def _zone_lookup(self, j): 294 | if not j['response']['zone_exists']: 295 | log("Zone %s not exists or is not under user %s", 296 | (j['request']['zone_name'].encode('utf-8'), self.user_email)) 297 | return 298 | log("SSL status: %s", (i18n(j['response']['ssl_status'].encode('utf-8').decode('ascii')))) 299 | print("%-32s%-24s%-32s" % (i18n("Subdomain"), i18n("Resolve to"), i18n("CNAME"))) 300 | print("-" * 80) 301 | tos = j['response']['forward_tos'] 302 | hosted = j['response']['hosted_cnames'] 303 | if not tos or not hosted: 304 | return 305 | ssl_cname, ssl_resolve_to = None, None 306 | for z in tos.keys(): 307 | if hosted[z].endswith("comodoca.com"): 308 | ssl_cname = z.encode('utf-8') 309 | ssl_resolve_to = hosted[z].encode('utf-8') 310 | continue 311 | print("%-32s%-32s%-32s" % (z, hosted[z], tos[z])) 312 | if ssl_cname and j['response']['ssl_status'] != "ready": 313 | print("") 314 | log("If you want to activate SSL, please set CNAME record of %s to %s and " \ 315 | "run this \"ssl_verfication\" after record become effective", (ssl_cname, ssl_resolve_to)) 316 | 317 | @catch_err 318 | def _host_key_regen(self, j): 319 | global HOSTKEY 320 | HOSTKEY = j['request']['host_key']['__host_key'].encode('utf-8') 321 | check_hostkey(force = True) 322 | log("Host key has been changed to %s", HOSTKEY) 323 | 324 | def __getattr__(self, act, handle=True): 325 | if act not in CFARG: 326 | raise AttributeError("'CF' object has no attribute '%s'" % act) 327 | return lambda k={}:getattr(self, "_%s" % act)(self._hostapi(act, k)) 328 | 329 | def check_hostkey(force = False): 330 | global HOSTKEY 331 | if HOSTKEY and len(HOSTKEY) == 32 and not force: 332 | return 333 | while not HOSTKEY or len(HOSTKEY) != 32: 334 | HOSTKEY = raw_input(i18n("Please enter your Cloudflare partner hostkey (https://partners.cloudflare.com/api-management)> ")).strip() 335 | import re 336 | with open(__file__, "rb") as f: 337 | script = f.read() 338 | if PY3K: 339 | HOSTKEY = HOSTKEY.encode('ascii') 340 | script = re.sub(b"HOSTKEY.+HOSTKEY_ANCHOR", b"HOSTKEY = \"%s\" # HOSTKEY_ANCHOR" % HOSTKEY, script, count = 1) 341 | with open(__file__, 'wb') as f: 342 | f.write(script) 343 | 344 | def menu(act = None): 345 | if not act: 346 | acts = [k for k in sorted(CFARG.keys()) if k != "user_auth"] + ["logout"] 347 | print("=" * 32) 348 | print(i18n("Select your action:")) 349 | for i in range(len(acts)): 350 | print("%d. %s" % (i + 1, i18n(acts[i]))) 351 | s = raw_input("> ").strip() 352 | if not s.isdigit() or int(s) not in range(1, len(acts) + 1): 353 | return None, None 354 | act = acts[int(s) - 1] 355 | arg = {} 356 | if act in CFARG: 357 | for k in CFARG[act]: 358 | while True: 359 | arg[k] = raw_input("%s > " % i18n(k)) 360 | if arg[k]: 361 | break 362 | return act, arg 363 | 364 | if __name__ == '__main__': 365 | try: 366 | check_hostkey() 367 | cf = CF() 368 | while not cf.user_key: 369 | act, arg = menu(act = "user_auth") 370 | cf.user_auth(arg) 371 | while True: 372 | act, arg = menu() 373 | if not act: 374 | continue 375 | getattr(cf, act)(arg) 376 | except (KeyboardInterrupt, EOFError): 377 | os._exit(0) 378 | --------------------------------------------------------------------------------