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 +3 -0
- hipp_agent/__main__.py +83 -0
- hipp_agent/certinfo.py +42 -0
- hipp_agent/config.py +300 -0
- hipp_agent/daemon.py +220 -0
- hipp_agent/install.py +252 -0
- hipp_agent/register.py +134 -0
- hipp_agent/render.py +95 -0
- hipp_agent/report.py +65 -0
- hipp_agent/runner.py +266 -0
- hipp_https_agent-0.2.0.dist-info/METADATA +86 -0
- hipp_https_agent-0.2.0.dist-info/RECORD +15 -0
- hipp_https_agent-0.2.0.dist-info/WHEEL +5 -0
- hipp_https_agent-0.2.0.dist-info/entry_points.txt +2 -0
- hipp_https_agent-0.2.0.dist-info/top_level.txt +1 -0
hipp_agent/__init__.py
ADDED
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
|