hipp-https-agent 0.2.0__py3-none-any.whl

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.
hipp_agent/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """为 hipp.ink 与 hipp.site 调用系统 certbot 申请和续期证书。"""
2
+
3
+ __version__ = "0.2.0"
hipp_agent/__main__.py ADDED
@@ -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())
hipp_agent/certinfo.py ADDED
@@ -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()
hipp_agent/config.py ADDED
@@ -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
hipp_agent/daemon.py ADDED
@@ -0,0 +1,220 @@
1
+ """证书端常驻进程:按申请、生效、监听推进,并定期告诉服务端自己还在运行。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from hipp_agent.certinfo import read_certificate
12
+ from hipp_agent.config import AppConfig, CertificateSpec
13
+ from hipp_agent.install import PublishResult, certificate_stage, describe_installed
14
+ from hipp_agent.render import challenge_help
15
+ from hipp_agent.report import push_report
16
+ from hipp_agent.register import refresh
17
+ from hipp_agent.runner import PreflightError, deploy, issue, preflight, renew
18
+
19
+ HEARTBEAT_SECONDS = 60
20
+ RENEW_SECONDS = 12 * 60 * 60
21
+
22
+
23
+ def run_daemon(config: AppConfig, sleep=time.sleep, clock=time.time) -> None:
24
+ record = EffectRecord(config.path)
25
+ attempted: dict[str, float] = {}
26
+ errors: dict[str, str] = {}
27
+ waiting: dict[str, str] = {}
28
+ renew_at = 0.0
29
+ renew_failed = False
30
+ while True:
31
+ now = clock()
32
+ moment = datetime.fromtimestamp(now, timezone.utc)
33
+ synced = refresh(config)
34
+ if synced:
35
+ print(synced, flush=True)
36
+ _advance(config, now, attempted, errors, waiting)
37
+ if _watch_due(config, moment) and _renew_due(now, renew_at, renew_failed, config.retry_seconds):
38
+ code, text = renew(config, apply=True, dry_run=False)
39
+ _print(text)
40
+ renew_at = now
41
+ renew_failed = code != 0
42
+ if code != 0:
43
+ print(f"本轮续期退出码 {code}", flush=True)
44
+ for spec in config.certificates:
45
+ if spec.enabled:
46
+ record.observe(spec, moment)
47
+ results = describe_installed(config)
48
+ _annotate(results, config, record, errors, waiting)
49
+ print(push_report(config, results, agent_status="running"), flush=True)
50
+ sleep(HEARTBEAT_SECONDS)
51
+
52
+
53
+ def _advance(
54
+ config: AppConfig,
55
+ now: float,
56
+ attempted: dict[str, float],
57
+ errors: dict[str, str],
58
+ waiting: dict[str, str],
59
+ ) -> None:
60
+ apply_names: list[str] = []
61
+ activate_names: list[str] = []
62
+ for spec in config.certificates:
63
+ if not spec.enabled:
64
+ continue
65
+ stage = certificate_stage(spec)
66
+ if stage != "apply":
67
+ waiting.pop(spec.name, None)
68
+ if stage == "watch":
69
+ errors.pop(spec.name, None)
70
+ continue
71
+ previous = attempted.get(spec.name)
72
+ if previous is not None and now - previous < config.retry_seconds:
73
+ continue
74
+ if stage == "apply":
75
+ apply_names.append(spec.name)
76
+ else:
77
+ activate_names.append(spec.name)
78
+ ready: list[str] = []
79
+ for name in apply_names:
80
+ spec = next(item for item in config.certificates if item.name == name)
81
+ try:
82
+ for domain in spec.domains:
83
+ preflight(domain, Path(config.webroot))
84
+ except PreflightError as exc:
85
+ waiting[name] = str(exc)
86
+ errors[name] = str(exc)
87
+ attempted[name] = now
88
+ print(f"{name}:等待校验。{exc}", flush=True)
89
+ continue
90
+ waiting.pop(name, None)
91
+ ready.append(name)
92
+ if ready:
93
+ code, text = issue(config, apply=True, only=set(ready))
94
+ _print(text)
95
+ _remember(ready, code, text, now, attempted, errors)
96
+ if activate_names:
97
+ code, text = deploy(config, apply=True, only=set(activate_names))
98
+ _print(text)
99
+ _remember(activate_names, code, text, now, attempted, errors)
100
+
101
+
102
+ def _remember(
103
+ names: list[str],
104
+ code: int,
105
+ text: str,
106
+ now: float,
107
+ attempted: dict[str, float],
108
+ errors: dict[str, str],
109
+ ) -> None:
110
+ clipped = _clip(text)
111
+ for name in names:
112
+ attempted[name] = now
113
+ if code != 0:
114
+ errors[name] = clipped
115
+ else:
116
+ errors.pop(name, None)
117
+
118
+
119
+ def _watch_due(config: AppConfig, moment: datetime) -> bool:
120
+ for spec in config.certificates:
121
+ if not spec.enabled or certificate_stage(spec) != "watch":
122
+ continue
123
+ _not_before, not_after, _issuer = read_certificate(Path(spec.cert_file))
124
+ if _within_window(not_after, spec.validity_days, moment):
125
+ return True
126
+ return False
127
+
128
+
129
+ def _within_window(not_after: str, validity_days: int, moment: datetime) -> bool:
130
+ if not not_after:
131
+ return False
132
+ try:
133
+ end = datetime.fromisoformat(not_after)
134
+ except ValueError:
135
+ return False
136
+ if end.tzinfo is None:
137
+ end = end.replace(tzinfo=timezone.utc)
138
+ remaining_days = (end - moment).total_seconds() / 86400
139
+ return remaining_days <= max(1, validity_days // 3)
140
+
141
+
142
+ def _renew_due(now: float, renew_at: float, failed: bool, retry_seconds: int) -> bool:
143
+ if renew_at == 0.0:
144
+ return True
145
+ wait = retry_seconds if failed else RENEW_SECONDS
146
+ return now - renew_at >= wait
147
+
148
+
149
+ def _annotate(
150
+ results: list[PublishResult],
151
+ config: AppConfig,
152
+ record: "EffectRecord",
153
+ errors: dict[str, str],
154
+ waiting: dict[str, str],
155
+ ) -> None:
156
+ specs = {item.name: item for item in config.certificates}
157
+ for result in results:
158
+ spec = specs.get(result.name)
159
+ if spec is None:
160
+ continue
161
+ result.stage = "challenge" if spec.name in waiting else certificate_stage(spec)
162
+ result.validity_days = spec.validity_days
163
+ result.effective_at = record.effective_at(spec.name)
164
+ result.help = challenge_help(config.webroot, spec.domains) if spec.name in waiting else ""
165
+ if result.stage != "watch" and errors.get(spec.name):
166
+ result.detail = errors[spec.name]
167
+ if result.stage != "watch":
168
+ suffix = f"{config.retry_seconds} 秒后重试"
169
+ base = result.detail.rstrip("。 ")
170
+ if not base.endswith(suffix):
171
+ room = 300 - len(suffix) - 1
172
+ result.detail = f"{base[:room].rstrip('。')}。{suffix}"
173
+
174
+
175
+ def _print(text: str) -> None:
176
+ print(text, end="" if text.endswith("\n") else "\n", flush=True)
177
+
178
+
179
+ def _clip(text: str) -> str:
180
+ return " ".join(text.split())[:300]
181
+
182
+
183
+ class EffectRecord:
184
+ """记住每张证书第一次生效的时间。证书换成新的之后才更新。"""
185
+
186
+ def __init__(self, config_path: Path) -> None:
187
+ self.path = config_path.with_suffix(".state.json")
188
+ self._items: dict[str, dict] = {}
189
+ if self.path.is_file():
190
+ try:
191
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
192
+ except (OSError, json.JSONDecodeError):
193
+ raw = {}
194
+ if isinstance(raw, dict):
195
+ self._items = {key: value for key, value in raw.items() if isinstance(value, dict)}
196
+
197
+ def effective_at(self, name: str) -> str:
198
+ value = (self._items.get(name) or {}).get("effective_at") or ""
199
+ return value if isinstance(value, str) else ""
200
+
201
+ def observe(self, spec: CertificateSpec, moment: datetime) -> None:
202
+ if not Path(spec.cert_file).is_file():
203
+ return
204
+ not_before, _not_after, _issuer = read_certificate(Path(spec.cert_file))
205
+ current = self._items.get(spec.name) or {}
206
+ previous = current.get("not_before") or ""
207
+ if current.get("effective_at") and (not not_before or not_before == previous):
208
+ return
209
+ self._items[spec.name] = {
210
+ "effective_at": moment.astimezone(timezone.utc).isoformat(),
211
+ "not_before": not_before,
212
+ }
213
+ self._save()
214
+
215
+ def _save(self) -> None:
216
+ self.path.write_text(json.dumps(self._items, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
217
+ try:
218
+ os.chmod(self.path, 0o600)
219
+ except OSError:
220
+ pass