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.
- hipp_https_agent-0.2.0/PKG-INFO +86 -0
- hipp_https_agent-0.2.0/README.md +79 -0
- hipp_https_agent-0.2.0/hipp_agent/__init__.py +3 -0
- hipp_https_agent-0.2.0/hipp_agent/__main__.py +83 -0
- hipp_https_agent-0.2.0/hipp_agent/certinfo.py +42 -0
- hipp_https_agent-0.2.0/hipp_agent/config.py +300 -0
- hipp_https_agent-0.2.0/hipp_agent/daemon.py +220 -0
- hipp_https_agent-0.2.0/hipp_agent/install.py +252 -0
- hipp_https_agent-0.2.0/hipp_agent/register.py +134 -0
- hipp_https_agent-0.2.0/hipp_agent/render.py +95 -0
- hipp_https_agent-0.2.0/hipp_agent/report.py +65 -0
- hipp_https_agent-0.2.0/hipp_agent/runner.py +266 -0
- hipp_https_agent-0.2.0/hipp_https_agent.egg-info/PKG-INFO +86 -0
- hipp_https_agent-0.2.0/hipp_https_agent.egg-info/SOURCES.txt +17 -0
- hipp_https_agent-0.2.0/hipp_https_agent.egg-info/dependency_links.txt +1 -0
- hipp_https_agent-0.2.0/hipp_https_agent.egg-info/entry_points.txt +2 -0
- hipp_https_agent-0.2.0/hipp_https_agent.egg-info/top_level.txt +1 -0
- hipp_https_agent-0.2.0/pyproject.toml +18 -0
- hipp_https_agent-0.2.0/setup.cfg +4 -0
|
@@ -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,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
|