hipp-https-agent 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: hipp-https-agent
3
+ Version: 0.2.0
4
+ Summary: 在本机申请和续期 HTTPS 证书,并用令牌向服务端上报
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # hipp-https-agent
9
+
10
+ 在需要证书的机器上申请和续期 Let’s Encrypt 证书,并把状态上报到服务端。不需要远程登录这台机器。
11
+
12
+ 服务端地址是 `https://hipp.site/cert/`。程序会自己接上 `/api/...`,不要再加接口路径。末尾斜杠可以留着。
13
+
14
+ ## 安装
15
+
16
+ 在证书机器上用 root 安装。申请证书、写 `/etc/nginx/certs` 和重新加载 Nginx 都需要 root,常驻进程也用这个用户。
17
+
18
+ ```sh
19
+ sudo pip install hipp-https-agent
20
+ ```
21
+
22
+ 安装后得到命令 `hipp-agent`。包在 PyPI 上的名字是 `hipp-https-agent`。
23
+
24
+ 先装好 certbot,并准备校验目录:
25
+
26
+ ```sh
27
+ sudo apt-get install -y certbot
28
+ sudo mkdir -p /var/www/acme
29
+ ```
30
+
31
+ ## 登记
32
+
33
+ 先在 [https://hipp.site/cert/](https://hipp.site/cert/) 用手机号登录,打开「证书端令牌」,按页面上的安装和登记范例操作。证书端名称要填这台机器的 `hostname`。
34
+
35
+ 再用 root 登记,这样配置会写到 root 的家目录,和后面的常驻进程是同一个用户:
36
+
37
+ ```sh
38
+ sudo hipp-agent register -s https://hipp.site/cert/ -t <令牌>
39
+ ```
40
+
41
+ `-s` 是服务端地址。`-t` 是「证书端令牌」页里的令牌。
42
+
43
+ 配置在 `/root/.config/hipp-agent/config.json`。控制台里与本机 hostname 同名的域名会一并写入。还没有域名时也可以先登记,补上之后常驻进程会自动取回。
44
+
45
+ ## 后台常驻
46
+
47
+ `hipp-agent run` 是常驻循环:每轮向服务端取配置,按申请、生效、监听推进,并上报心跳。它不会自己退出。直接在终端里跑时,关掉终端进程就没了。
48
+
49
+ 用 systemd 把它留在后台,断线、重启机器之后也会再拉起来:
50
+
51
+ ```sh
52
+ sudo tee /etc/systemd/system/hipp-agent.service >/dev/null <<'EOF'
53
+ [Unit]
54
+ Description=hipp certificate agent
55
+ After=network-online.target
56
+ Wants=network-online.target
57
+
58
+ [Service]
59
+ ExecStart=/usr/local/bin/hipp-agent --config /root/.config/hipp-agent/config.json run
60
+ Restart=always
61
+ RestartSec=10
62
+
63
+ [Install]
64
+ WantedBy=multi-user.target
65
+ EOF
66
+ sudo systemctl daemon-reload
67
+ sudo systemctl enable --now hipp-agent
68
+ ```
69
+
70
+ 若 `command -v hipp-agent` 不是 `/usr/local/bin/hipp-agent`,先改单元里的 `ExecStart` 再执行。仓库里的 `deploy/hipp-agent.service` 与上面这段相同,给仍从源码安装的人用。
71
+
72
+ 查看是否在跑:
73
+
74
+ ```sh
75
+ systemctl status hipp-agent
76
+ journalctl -u hipp-agent -n 50 --no-pager
77
+ ```
78
+
79
+ 停掉或重新拉起:
80
+
81
+ ```sh
82
+ sudo systemctl stop hipp-agent
83
+ sudo systemctl restart hipp-agent
84
+ ```
85
+
86
+ 控制台「证书状态」里,最后上报在 3 分钟内为程序在运行。
@@ -0,0 +1,79 @@
1
+ # hipp-https-agent
2
+
3
+ 在需要证书的机器上申请和续期 Let’s Encrypt 证书,并把状态上报到服务端。不需要远程登录这台机器。
4
+
5
+ 服务端地址是 `https://hipp.site/cert/`。程序会自己接上 `/api/...`,不要再加接口路径。末尾斜杠可以留着。
6
+
7
+ ## 安装
8
+
9
+ 在证书机器上用 root 安装。申请证书、写 `/etc/nginx/certs` 和重新加载 Nginx 都需要 root,常驻进程也用这个用户。
10
+
11
+ ```sh
12
+ sudo pip install hipp-https-agent
13
+ ```
14
+
15
+ 安装后得到命令 `hipp-agent`。包在 PyPI 上的名字是 `hipp-https-agent`。
16
+
17
+ 先装好 certbot,并准备校验目录:
18
+
19
+ ```sh
20
+ sudo apt-get install -y certbot
21
+ sudo mkdir -p /var/www/acme
22
+ ```
23
+
24
+ ## 登记
25
+
26
+ 先在 [https://hipp.site/cert/](https://hipp.site/cert/) 用手机号登录,打开「证书端令牌」,按页面上的安装和登记范例操作。证书端名称要填这台机器的 `hostname`。
27
+
28
+ 再用 root 登记,这样配置会写到 root 的家目录,和后面的常驻进程是同一个用户:
29
+
30
+ ```sh
31
+ sudo hipp-agent register -s https://hipp.site/cert/ -t <令牌>
32
+ ```
33
+
34
+ `-s` 是服务端地址。`-t` 是「证书端令牌」页里的令牌。
35
+
36
+ 配置在 `/root/.config/hipp-agent/config.json`。控制台里与本机 hostname 同名的域名会一并写入。还没有域名时也可以先登记,补上之后常驻进程会自动取回。
37
+
38
+ ## 后台常驻
39
+
40
+ `hipp-agent run` 是常驻循环:每轮向服务端取配置,按申请、生效、监听推进,并上报心跳。它不会自己退出。直接在终端里跑时,关掉终端进程就没了。
41
+
42
+ 用 systemd 把它留在后台,断线、重启机器之后也会再拉起来:
43
+
44
+ ```sh
45
+ sudo tee /etc/systemd/system/hipp-agent.service >/dev/null <<'EOF'
46
+ [Unit]
47
+ Description=hipp certificate agent
48
+ After=network-online.target
49
+ Wants=network-online.target
50
+
51
+ [Service]
52
+ ExecStart=/usr/local/bin/hipp-agent --config /root/.config/hipp-agent/config.json run
53
+ Restart=always
54
+ RestartSec=10
55
+
56
+ [Install]
57
+ WantedBy=multi-user.target
58
+ EOF
59
+ sudo systemctl daemon-reload
60
+ sudo systemctl enable --now hipp-agent
61
+ ```
62
+
63
+ 若 `command -v hipp-agent` 不是 `/usr/local/bin/hipp-agent`,先改单元里的 `ExecStart` 再执行。仓库里的 `deploy/hipp-agent.service` 与上面这段相同,给仍从源码安装的人用。
64
+
65
+ 查看是否在跑:
66
+
67
+ ```sh
68
+ systemctl status hipp-agent
69
+ journalctl -u hipp-agent -n 50 --no-pager
70
+ ```
71
+
72
+ 停掉或重新拉起:
73
+
74
+ ```sh
75
+ sudo systemctl stop hipp-agent
76
+ sudo systemctl restart hipp-agent
77
+ ```
78
+
79
+ 控制台「证书状态」里,最后上报在 3 分钟内为程序在运行。
@@ -0,0 +1,3 @@
1
+ """为 hipp.ink 与 hipp.site 调用系统 certbot 申请和续期证书。"""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,83 @@
1
+ """命令行入口。不加 --apply 时只打印,不向证书机构申请。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from hipp_agent.config import (
10
+ ConfigError,
11
+ default_config_path,
12
+ load_config,
13
+ project_root,
14
+ resolve_config_path,
15
+ )
16
+ from hipp_agent.daemon import run_daemon
17
+ from hipp_agent.register import register_agent
18
+ from hipp_agent.runner import build_plan, deploy, install_hook, issue, renew, report
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ parser = argparse.ArgumentParser(description="为 hipp.ink 与 hipp.site 申请和续期 HTTPS 证书")
23
+ parser.add_argument("--config", help="配置文件路径。默认使用登记后的用户配置,否则用当前目录的 config.json")
24
+ sub = parser.add_subparsers(dest="command", required=True)
25
+
26
+ register_cmd = sub.add_parser("register", help="用服务端地址和令牌登记本机")
27
+ register_cmd.add_argument("-s", "--server", required=True, help="服务端根地址,不要带具体接口路径")
28
+ register_cmd.add_argument("-t", "--token", required=True, help="业务配置中的令牌")
29
+
30
+ plan = sub.add_parser("plan", help="打印命令并写出 Nginx 片段")
31
+ plan.add_argument("--out", default="", help="片段输出目录,默认 <仓库>/out")
32
+
33
+ issue_cmd = sub.add_parser("issue", help="探测通过后调用 certbot 申请")
34
+ issue_cmd.add_argument("--apply", action="store_true", help="真正执行。省略时只打印")
35
+
36
+ renew_cmd = sub.add_parser("renew", help="在本机调用 certbot renew,并安装到指定路径")
37
+ renew_cmd.add_argument("--apply", action="store_true", help="真正执行。省略时只打印")
38
+ renew_cmd.add_argument("--dry-run", action="store_true", help="与 --apply 合用,只演练续期")
39
+
40
+ deploy_cmd = sub.add_parser("deploy", help="把已签出的证书写到指定文件名并重新加载 Nginx")
41
+ deploy_cmd.add_argument("--apply", action="store_true", help="真正执行。省略时只打印")
42
+
43
+ report_cmd = sub.add_parser("report", help="读取已安装证书并上报,不重新加载 Nginx")
44
+ report_cmd.add_argument("--apply", action="store_true", help="真正上报。省略时只打印")
45
+
46
+ hook_cmd = sub.add_parser("install-hook", help="安装续期后写证书并重新加载 Nginx 的钩子")
47
+ hook_cmd.add_argument("--apply", action="store_true", help="真正写入。省略时只打印")
48
+
49
+ sub.add_parser("run", help="常驻:按周期续期、写证书,并向服务端心跳")
50
+
51
+ args = parser.parse_args(argv)
52
+ try:
53
+ if args.command == "register":
54
+ path = Path(args.config) if args.config else default_config_path()
55
+ sys.stdout.write(register_agent(args.server, args.token, path))
56
+ return 0
57
+ config = load_config(resolve_config_path(args.config))
58
+ if args.command == "run":
59
+ run_daemon(config)
60
+ return 0
61
+ if args.command == "plan":
62
+ out = Path(args.out) if args.out else project_root() / "out"
63
+ sys.stdout.write(build_plan(config, out))
64
+ return 0
65
+ if args.command == "renew":
66
+ code, text = renew(config, apply=args.apply, dry_run=args.dry_run)
67
+ elif args.command == "deploy":
68
+ code, text = deploy(config, apply=args.apply)
69
+ elif args.command == "report":
70
+ code, text = report(config, apply=args.apply)
71
+ elif args.command == "install-hook":
72
+ code, text = install_hook(config, apply=args.apply)
73
+ else:
74
+ code, text = issue(config, apply=args.apply)
75
+ sys.stdout.write(text)
76
+ return code
77
+ except (ConfigError, PermissionError, OSError) as exc:
78
+ sys.stderr.write(str(exc) + "\n")
79
+ return 2
80
+
81
+
82
+ if __name__ == "__main__":
83
+ raise SystemExit(main())
@@ -0,0 +1,42 @@
1
+ """从已安装的证书文件读取到期时间。不读取私钥。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+
11
+ def read_certificate(path: Path) -> tuple[str, str, str]:
12
+ """返回 not_before、not_after 的 UTC 时间和颁发者。读不到时均为空字符串。"""
13
+ if not path.is_file() or shutil.which("openssl") is None:
14
+ return "", "", ""
15
+ completed = subprocess.run(
16
+ ["openssl", "x509", "-in", str(path), "-noout", "-startdate", "-enddate", "-issuer"],
17
+ capture_output=True,
18
+ text=True,
19
+ check=False,
20
+ )
21
+ if completed.returncode != 0:
22
+ return "", "", ""
23
+ not_before = ""
24
+ not_after = ""
25
+ issuer = ""
26
+ for line in completed.stdout.splitlines():
27
+ if line.startswith("notBefore="):
28
+ not_before = _openssl_time(line.split("=", 1)[1])
29
+ elif line.startswith("notAfter="):
30
+ not_after = _openssl_time(line.split("=", 1)[1])
31
+ elif line.startswith("issuer="):
32
+ issuer = line.split("=", 1)[1].strip()
33
+ return not_before, not_after, issuer
34
+
35
+
36
+ def _openssl_time(value: str) -> str:
37
+ text = value.strip().removesuffix(" GMT")
38
+ try:
39
+ parsed = datetime.strptime(text, "%b %d %H:%M:%S %Y")
40
+ except ValueError:
41
+ return ""
42
+ return parsed.replace(tzinfo=timezone.utc).isoformat()
@@ -0,0 +1,300 @@
1
+ """读取并校验证书配置。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ class ConfigError(ValueError):
11
+ """配置不符合签发要求。"""
12
+
13
+
14
+ @dataclass
15
+ class CertificateSpec:
16
+ name: str
17
+ domains: list[str]
18
+ tls: str
19
+ enabled: bool
20
+ cert_file: str
21
+ key_file: str
22
+ validity_days: int = 90
23
+ account_email: str = ""
24
+ agree_tos: bool | None = None
25
+ organization: str = ""
26
+ organization_unit: str = ""
27
+
28
+
29
+ @dataclass
30
+ class AppConfig:
31
+ account_email: str
32
+ agree_tos: bool
33
+ webroot: str
34
+ staging: bool
35
+ server_name: str
36
+ report_url: str
37
+ report_token: str
38
+ certificates: list[CertificateSpec]
39
+ path: Path
40
+ retry_seconds: int = 60
41
+ organization: str = ""
42
+ organization_unit: str = ""
43
+
44
+
45
+ def project_root() -> Path:
46
+ return Path(__file__).resolve().parents[1]
47
+
48
+
49
+ def default_config_path() -> Path:
50
+ return Path.home() / ".config" / "hipp-agent" / "config.json"
51
+
52
+
53
+ def resolve_config_path(explicit: str | None) -> Path:
54
+ if explicit:
55
+ return Path(explicit)
56
+ home = default_config_path()
57
+ if home.is_file():
58
+ return home
59
+ root = project_root()
60
+ local = root / "config.json"
61
+ if local.is_file():
62
+ return local
63
+ example = root / "config.example.json"
64
+ if example.is_file():
65
+ return example
66
+ return home
67
+
68
+
69
+ def load_config(path: Path) -> AppConfig:
70
+ try:
71
+ raw = json.loads(path.read_text(encoding="utf-8"))
72
+ except FileNotFoundError as exc:
73
+ raise ConfigError(f"找不到配置文件:{path}") from exc
74
+ except json.JSONDecodeError as exc:
75
+ raise ConfigError(f"配置不是合法 JSON:{path}({exc})") from exc
76
+ return parse_config(raw, path)
77
+
78
+
79
+ def parse_config(raw: object, path: Path) -> AppConfig:
80
+ if not isinstance(raw, dict):
81
+ raise ConfigError("配置根节点必须是对象")
82
+
83
+ webroot = _abs_path(raw.get("webroot"), "webroot")
84
+ email = raw.get("account_email", "")
85
+ if not isinstance(email, str):
86
+ raise ConfigError("account_email 必须是字符串")
87
+ agree = raw.get("agree_tos", False)
88
+ if not isinstance(agree, bool):
89
+ raise ConfigError("agree_tos 必须是 true 或 false")
90
+ staging = raw.get("staging", False)
91
+ if not isinstance(staging, bool):
92
+ raise ConfigError("staging 必须是 true 或 false")
93
+
94
+ server_name = raw.get("server_name", "")
95
+ if not isinstance(server_name, str) or any(char in server_name for char in "\r\n"):
96
+ raise ConfigError("server_name 必须是一行内的字符串")
97
+ server_name = server_name.strip()
98
+ if len(server_name) > 64:
99
+ raise ConfigError("server_name 最长 64 个字符")
100
+
101
+ report_url = raw.get("report_url", "")
102
+ if not isinstance(report_url, str):
103
+ raise ConfigError("report_url 必须是字符串")
104
+ report_url = report_url.strip().rstrip("/")
105
+ if report_url and not (report_url.startswith("http://") or report_url.startswith("https://")):
106
+ raise ConfigError("report_url 必须以 http:// 或 https:// 开头")
107
+ report_token = raw.get("report_token", "")
108
+ if not isinstance(report_token, str):
109
+ raise ConfigError("report_token 必须是字符串")
110
+ report_token = report_token.strip()
111
+ if report_url and not report_token:
112
+ raise ConfigError("写了 report_url 时必须填写 report_token")
113
+ if any(char.isspace() for char in report_token):
114
+ raise ConfigError("report_token 不能含空白")
115
+
116
+ items = raw.get("certificates")
117
+ if items is None:
118
+ items = []
119
+ if not isinstance(items, list):
120
+ raise ConfigError("certificates 必须是数组")
121
+
122
+ specs = [_certificate(item, webroot) for item in items]
123
+ names = [item.name for item in specs]
124
+ if len(names) != len(set(names)):
125
+ raise ConfigError("certificates[].name 不能重复")
126
+ retry_seconds = _bounded_int(raw.get("retry_seconds", 60), 60, 3600, "retry_seconds")
127
+ organization = _optional_line(raw.get("organization", ""), "organization")
128
+ organization_unit = _optional_line(raw.get("organization_unit", ""), "organization_unit")
129
+
130
+ return AppConfig(
131
+ account_email=email.strip(),
132
+ agree_tos=agree,
133
+ webroot=webroot,
134
+ staging=staging,
135
+ server_name=server_name,
136
+ report_url=report_url,
137
+ report_token=report_token,
138
+ certificates=specs,
139
+ path=path,
140
+ retry_seconds=retry_seconds,
141
+ organization=organization,
142
+ organization_unit=organization_unit,
143
+ )
144
+
145
+
146
+ def dump_config(config: AppConfig) -> dict:
147
+ return {
148
+ "account_email": config.account_email,
149
+ "agree_tos": config.agree_tos,
150
+ "organization": config.organization,
151
+ "organization_unit": config.organization_unit,
152
+ "webroot": config.webroot,
153
+ "staging": config.staging,
154
+ "server_name": config.server_name,
155
+ "report_url": config.report_url,
156
+ "report_token": config.report_token,
157
+ "retry_seconds": config.retry_seconds,
158
+ "certificates": [
159
+ {
160
+ "name": item.name,
161
+ "domains": item.domains,
162
+ "tls": item.tls,
163
+ "enabled": item.enabled,
164
+ "cert_file": item.cert_file,
165
+ "key_file": item.key_file,
166
+ "validity_days": item.validity_days,
167
+ "account_email": item.account_email,
168
+ "agree_tos": item.agree_tos,
169
+ "organization": item.organization,
170
+ "organization_unit": item.organization_unit,
171
+ }
172
+ for item in config.certificates
173
+ ],
174
+ }
175
+
176
+
177
+ def write_config(path: Path, raw: dict) -> AppConfig:
178
+ """先校验,再写入。校验失败时不改已有文件。"""
179
+ config = parse_config(raw, path)
180
+ path.parent.mkdir(parents=True, exist_ok=True)
181
+ temporary = path.with_suffix(path.suffix + ".tmp")
182
+ temporary.write_text(json.dumps(dump_config(config), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
183
+ temporary.chmod(0o600)
184
+ temporary.replace(path)
185
+ path.chmod(0o600)
186
+ return config
187
+
188
+
189
+ def _optional_line(value: object, label: str) -> str:
190
+ if value is None:
191
+ return ""
192
+ if not isinstance(value, str):
193
+ raise ConfigError(f"{label} 必须是字符串")
194
+ text = value.strip()
195
+ if any(char in text for char in "\r\n") or len(text) > 64:
196
+ raise ConfigError(f"{label} 须为一行,最长 64 个字符")
197
+ return text
198
+
199
+
200
+ def certificate_email(config: AppConfig, spec: CertificateSpec) -> str:
201
+ return spec.account_email or config.account_email
202
+
203
+
204
+ def certificate_agreed(config: AppConfig, spec: CertificateSpec) -> bool:
205
+ if spec.agree_tos is None:
206
+ return config.agree_tos
207
+ return spec.agree_tos
208
+
209
+
210
+ def validate_for_issue(config: AppConfig) -> None:
211
+ enabled = [item for item in config.certificates if item.enabled]
212
+ if not enabled:
213
+ raise ConfigError("没有 enabled 为 true 的证书")
214
+
215
+
216
+ def _certificate(item: object, webroot: str) -> CertificateSpec:
217
+ if not isinstance(item, dict):
218
+ raise ConfigError("certificates 中的每一项必须是对象")
219
+ name = _domain(item.get("name"), "name")
220
+ domains_raw = item.get("domains")
221
+ if not isinstance(domains_raw, list) or not domains_raw:
222
+ raise ConfigError(f"{name} 的 domains 不能为空")
223
+ domains = [_domain(domain, f"{name} 的 domains") for domain in domains_raw]
224
+ if name not in domains:
225
+ raise ConfigError(f"{name} 必须出现在自己的 domains 里")
226
+ tls = item.get("tls")
227
+ if tls not in ("replace", "enable"):
228
+ raise ConfigError(f"{name} 的 tls 只能是 replace 或 enable")
229
+ enabled = item.get("enabled", True)
230
+ if not isinstance(enabled, bool):
231
+ raise ConfigError(f"{name} 的 enabled 必须是 true 或 false")
232
+ cert_file = _file_path(item.get("cert_file"), f"{name} 的 cert_file")
233
+ key_file = _file_path(item.get("key_file"), f"{name} 的 key_file")
234
+ if cert_file == key_file:
235
+ raise ConfigError(f"{name} 的证书文件和私钥文件不能是同一路径")
236
+ if _is_inside(cert_file, webroot) or _is_inside(key_file, webroot):
237
+ raise ConfigError(f"{name} 的证书和私钥不能放在校验目录 {webroot} 下面")
238
+ validity_days = _bounded_int(item.get("validity_days", 90), 1, 90, f"{name} 的 validity_days")
239
+ email = item.get("account_email", "")
240
+ if not isinstance(email, str):
241
+ raise ConfigError(f"{name} 的 account_email 必须是字符串")
242
+ agree = item.get("agree_tos", None)
243
+ if agree is not None and not isinstance(agree, bool):
244
+ raise ConfigError(f"{name} 的 agree_tos 必须是 true 或 false")
245
+ return CertificateSpec(
246
+ name=name,
247
+ domains=domains,
248
+ tls=tls,
249
+ enabled=enabled,
250
+ cert_file=cert_file,
251
+ key_file=key_file,
252
+ validity_days=validity_days,
253
+ account_email=email.strip(),
254
+ agree_tos=agree,
255
+ organization=_optional_line(item.get("organization", ""), f"{name} 的 organization"),
256
+ organization_unit=_optional_line(item.get("organization_unit", ""), f"{name} 的 organization_unit"),
257
+ )
258
+
259
+
260
+ def _bounded_int(value: object, low: int, high: int, label: str) -> int:
261
+ if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
262
+ raise ConfigError(f"{label} 必须是 {low} 到 {high} 的整数")
263
+ return value
264
+
265
+
266
+ def _domain(value: object, label: str) -> str:
267
+ if not isinstance(value, str):
268
+ raise ConfigError(f"{label} 必须是字符串")
269
+ domain = value.strip().lower().rstrip(".")
270
+ if not domain or any(char.isspace() for char in domain):
271
+ raise ConfigError(f"{label} 不是合法域名")
272
+ if "*" in domain:
273
+ raise ConfigError(f"{domain} 含有通配符。HTTP 文件校验不能申请通配符证书")
274
+ if "/" in domain or ".." in domain:
275
+ raise ConfigError(f"{label} 不是合法域名:{domain}")
276
+ return domain
277
+
278
+
279
+ def _file_path(value: object, label: str) -> str:
280
+ path = _abs_path(value, label)
281
+ if Path(path).name in ("", ".", ".."):
282
+ raise ConfigError(f"{label} 必须包含文件名")
283
+ return path
284
+
285
+
286
+ def _is_inside(child: str, parent: str) -> bool:
287
+ try:
288
+ Path(child).relative_to(parent)
289
+ except ValueError:
290
+ return False
291
+ return True
292
+
293
+
294
+ def _abs_path(value: object, label: str) -> str:
295
+ if not isinstance(value, str) or not value.startswith("/"):
296
+ raise ConfigError(f"{label} 必须是绝对路径")
297
+ path = value.rstrip("/") or "/"
298
+ if path == "/":
299
+ raise ConfigError(f"{label} 不能是文件系统根目录")
300
+ return path