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/install.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""把 certbot 签出的证书写到配置指定的文件名,并重新加载 Nginx。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from hipp_agent.certinfo import read_certificate
|
|
13
|
+
from hipp_agent.config import AppConfig, CertificateSpec, ConfigError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PublishResult:
|
|
18
|
+
name: str
|
|
19
|
+
domains: list[str]
|
|
20
|
+
cert_file: str
|
|
21
|
+
key_file: str
|
|
22
|
+
status: str
|
|
23
|
+
detail: str
|
|
24
|
+
not_before: str = ""
|
|
25
|
+
not_after: str = ""
|
|
26
|
+
issuer: str = ""
|
|
27
|
+
stage: str = ""
|
|
28
|
+
validity_days: int = 0
|
|
29
|
+
effective_at: str = ""
|
|
30
|
+
help: str = ""
|
|
31
|
+
archive_path: str = ""
|
|
32
|
+
previous_not_before: str = ""
|
|
33
|
+
previous_not_after: str = ""
|
|
34
|
+
previous_issuer: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _Backup:
|
|
39
|
+
path: Path
|
|
40
|
+
existed: bool
|
|
41
|
+
data: bytes | None
|
|
42
|
+
mode: int | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def nginx_reload_args(use_systemctl: bool) -> list[str]:
|
|
46
|
+
if use_systemctl:
|
|
47
|
+
return ["systemctl", "reload", "nginx"]
|
|
48
|
+
return ["nginx", "-s", "reload"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def reload_nginx() -> tuple[bool, str]:
|
|
52
|
+
test = subprocess.run(["nginx", "-t"], capture_output=True, text=True, check=False)
|
|
53
|
+
if test.returncode != 0:
|
|
54
|
+
return False, _clip(test.stderr or test.stdout or "nginx -t 未通过")
|
|
55
|
+
command = nginx_reload_args(shutil.which("systemctl") is not None)
|
|
56
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
57
|
+
if completed.returncode != 0:
|
|
58
|
+
return False, _clip(completed.stderr or completed.stdout or "重新加载 Nginx 失败")
|
|
59
|
+
return True, " ".join(command)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def publish_all(
|
|
63
|
+
config: AppConfig,
|
|
64
|
+
only: set[str] | None = None,
|
|
65
|
+
reload_fn=None,
|
|
66
|
+
live_root: Path | None = None,
|
|
67
|
+
) -> list[PublishResult]:
|
|
68
|
+
if config.staging:
|
|
69
|
+
raise ConfigError("演练证书不写入 Nginx 使用的路径")
|
|
70
|
+
if reload_fn is None:
|
|
71
|
+
reload_fn = reload_nginx
|
|
72
|
+
|
|
73
|
+
source_root = live_root or Path("/etc/letsencrypt/live")
|
|
74
|
+
results: list[PublishResult] = []
|
|
75
|
+
written: list[tuple[PublishResult, _Backup, _Backup]] = []
|
|
76
|
+
for spec in config.certificates:
|
|
77
|
+
if not spec.enabled:
|
|
78
|
+
continue
|
|
79
|
+
if only is not None and spec.name not in only:
|
|
80
|
+
continue
|
|
81
|
+
result, backups = _stage(spec, source_root)
|
|
82
|
+
results.append(result)
|
|
83
|
+
if backups is not None:
|
|
84
|
+
written.append((result, backups[0], backups[1]))
|
|
85
|
+
|
|
86
|
+
if not written:
|
|
87
|
+
return results
|
|
88
|
+
|
|
89
|
+
ok, detail = reload_fn()
|
|
90
|
+
if not ok:
|
|
91
|
+
for result, cert_backup, key_backup in written:
|
|
92
|
+
_restore(cert_backup)
|
|
93
|
+
_restore(key_backup)
|
|
94
|
+
_drop_archive(result)
|
|
95
|
+
result.status = "failed"
|
|
96
|
+
result.detail = detail
|
|
97
|
+
_fill_dates(result, Path(result.cert_file))
|
|
98
|
+
return results
|
|
99
|
+
|
|
100
|
+
for result, _cert_backup, _key_backup in written:
|
|
101
|
+
result.status = "reloaded"
|
|
102
|
+
result.detail = detail
|
|
103
|
+
_fill_dates(result, Path(result.cert_file))
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def certificate_stage(spec: CertificateSpec, live_root: Path | None = None) -> str:
|
|
108
|
+
"""申请:签发目录还没有;生效:指定路径未对齐;监听:两边一致。"""
|
|
109
|
+
root = live_root or Path("/etc/letsencrypt/live")
|
|
110
|
+
live_cert = root / spec.name / "fullchain.pem"
|
|
111
|
+
live_key = root / spec.name / "privkey.pem"
|
|
112
|
+
dest_cert = Path(spec.cert_file)
|
|
113
|
+
dest_key = Path(spec.key_file)
|
|
114
|
+
if not live_cert.is_file() or not live_key.is_file():
|
|
115
|
+
return "apply"
|
|
116
|
+
if not dest_cert.is_file() or not dest_key.is_file():
|
|
117
|
+
return "activate"
|
|
118
|
+
if not _same(live_cert, dest_cert) or not _same(live_key, dest_key):
|
|
119
|
+
return "activate"
|
|
120
|
+
return "watch"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def describe_installed(config: AppConfig) -> list[PublishResult]:
|
|
124
|
+
"""只读取已经放在指定路径上的证书,不写文件,也不重新加载。"""
|
|
125
|
+
results: list[PublishResult] = []
|
|
126
|
+
for spec in config.certificates:
|
|
127
|
+
if not spec.enabled:
|
|
128
|
+
continue
|
|
129
|
+
dest = Path(spec.cert_file)
|
|
130
|
+
if not dest.is_file():
|
|
131
|
+
results.append(_blank(spec, "missing", "指定路径上还没有证书文件"))
|
|
132
|
+
continue
|
|
133
|
+
result = _blank(spec, "unchanged", "仅上报,未重新加载")
|
|
134
|
+
_fill_dates(result, dest)
|
|
135
|
+
results.append(result)
|
|
136
|
+
return results
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _stage(spec: CertificateSpec, live_root: Path) -> tuple[PublishResult, tuple[_Backup, _Backup] | None]:
|
|
140
|
+
source_cert = live_root / spec.name / "fullchain.pem"
|
|
141
|
+
source_key = live_root / spec.name / "privkey.pem"
|
|
142
|
+
dest_cert = Path(spec.cert_file)
|
|
143
|
+
dest_key = Path(spec.key_file)
|
|
144
|
+
if dest_cert.is_dir() or dest_key.is_dir():
|
|
145
|
+
return _blank(spec, "failed", "目标路径是目录,不能当作证书文件"), None
|
|
146
|
+
if not source_cert.is_file() or not source_key.is_file():
|
|
147
|
+
return _blank(spec, "missing", "live 目录里还没有这张证书"), None
|
|
148
|
+
if _same(source_cert, dest_cert) and _same(source_key, dest_key):
|
|
149
|
+
_ensure_mode(dest_cert, 0o644)
|
|
150
|
+
_ensure_mode(dest_key, 0o600)
|
|
151
|
+
result = _blank(spec, "unchanged", "")
|
|
152
|
+
_fill_dates(result, dest_cert)
|
|
153
|
+
return result, None
|
|
154
|
+
|
|
155
|
+
cert_backup = _snapshot(dest_cert)
|
|
156
|
+
key_backup = _snapshot(dest_key)
|
|
157
|
+
result = _blank(spec, "pending", "")
|
|
158
|
+
if dest_cert.is_file():
|
|
159
|
+
result.previous_not_before, result.previous_not_after, result.previous_issuer = read_certificate(dest_cert)
|
|
160
|
+
result.archive_path = _copy_archive(spec, dest_cert, dest_key)
|
|
161
|
+
try:
|
|
162
|
+
_atomic_write(dest_cert, source_cert.read_bytes(), 0o644)
|
|
163
|
+
_atomic_write(dest_key, source_key.read_bytes(), 0o600)
|
|
164
|
+
except OSError:
|
|
165
|
+
_restore(cert_backup)
|
|
166
|
+
_restore(key_backup)
|
|
167
|
+
_drop_archive(result)
|
|
168
|
+
raise
|
|
169
|
+
return result, (cert_backup, key_backup)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _blank(spec: CertificateSpec, status: str, detail: str) -> PublishResult:
|
|
173
|
+
return PublishResult(
|
|
174
|
+
name=spec.name,
|
|
175
|
+
domains=list(spec.domains),
|
|
176
|
+
cert_file=spec.cert_file,
|
|
177
|
+
key_file=spec.key_file,
|
|
178
|
+
status=status,
|
|
179
|
+
detail=detail,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _fill_dates(result: PublishResult, path: Path) -> None:
|
|
184
|
+
not_before, not_after, issuer = read_certificate(path)
|
|
185
|
+
result.not_before = not_before
|
|
186
|
+
result.not_after = not_after
|
|
187
|
+
result.issuer = issuer
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _same(source: Path, dest: Path) -> bool:
|
|
191
|
+
if not dest.is_file():
|
|
192
|
+
return False
|
|
193
|
+
return source.read_bytes() == dest.read_bytes()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _ensure_mode(path: Path, mode: int) -> None:
|
|
197
|
+
if path.is_file() and (path.stat().st_mode & 0o777) != mode:
|
|
198
|
+
os.chmod(path, mode)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _copy_archive(spec: CertificateSpec, dest_cert: Path, dest_key: Path) -> str:
|
|
202
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
203
|
+
folder = dest_cert.parent / "archive" / spec.name / stamp
|
|
204
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
cert_copy = folder / "fullchain.pem"
|
|
206
|
+
cert_copy.write_bytes(dest_cert.read_bytes())
|
|
207
|
+
os.chmod(cert_copy, 0o644)
|
|
208
|
+
if dest_key.is_file():
|
|
209
|
+
key_copy = folder / "privkey.pem"
|
|
210
|
+
key_copy.write_bytes(dest_key.read_bytes())
|
|
211
|
+
os.chmod(key_copy, 0o600)
|
|
212
|
+
return str(folder)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _drop_archive(result: PublishResult) -> None:
|
|
216
|
+
folder = result.archive_path
|
|
217
|
+
result.archive_path = ""
|
|
218
|
+
result.previous_not_before = ""
|
|
219
|
+
result.previous_not_after = ""
|
|
220
|
+
result.previous_issuer = ""
|
|
221
|
+
if not folder:
|
|
222
|
+
return
|
|
223
|
+
path = Path(folder)
|
|
224
|
+
if path.is_dir():
|
|
225
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _snapshot(path: Path) -> _Backup:
|
|
229
|
+
if path.is_file():
|
|
230
|
+
return _Backup(path, True, path.read_bytes(), path.stat().st_mode & 0o777)
|
|
231
|
+
return _Backup(path, False, None, None)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _restore(backup: _Backup) -> None:
|
|
235
|
+
if not backup.existed:
|
|
236
|
+
if backup.path.is_file():
|
|
237
|
+
backup.path.unlink()
|
|
238
|
+
return
|
|
239
|
+
_atomic_write(backup.path, backup.data or b"", backup.mode or 0o644)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _atomic_write(path: Path, data: bytes, mode: int) -> None:
|
|
243
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
245
|
+
temporary.write_bytes(data)
|
|
246
|
+
os.chmod(temporary, mode)
|
|
247
|
+
os.replace(temporary, path)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _clip(text: str) -> str:
|
|
251
|
+
compact = " ".join(text.split())
|
|
252
|
+
return compact[:300]
|
hipp_agent/register.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""用令牌向服务端登记本机,并在运行中取回最新配置。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from hipp_agent.config import AppConfig, ConfigError, dump_config, parse_config, write_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_agent(server: str, token: str, path: Path) -> str:
|
|
16
|
+
root = _server(server)
|
|
17
|
+
secret = _token(token)
|
|
18
|
+
server_name = socket.gethostname().strip()
|
|
19
|
+
payload = _request(
|
|
20
|
+
"POST",
|
|
21
|
+
root + "/api/agent/register",
|
|
22
|
+
secret,
|
|
23
|
+
{"server_name": server_name},
|
|
24
|
+
)
|
|
25
|
+
config = write_config(path, _document(root, secret, payload, server_name))
|
|
26
|
+
if config.certificates:
|
|
27
|
+
names = "、".join(item.name for item in config.certificates)
|
|
28
|
+
return f"已登记 {config.server_name},配置写在 {path},证书:{names}\n"
|
|
29
|
+
return (
|
|
30
|
+
f"已登记 {config.server_name},配置写在 {path}。"
|
|
31
|
+
"控制台还没有这个名称的域名。补上之后执行 hipp-agent run,会自动取回。\n"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def refresh(config: AppConfig) -> str:
|
|
36
|
+
"""向服务端取该证书端的最新配置。没有变化时返回空字符串。"""
|
|
37
|
+
if not config.report_url or not config.report_token or not config.server_name:
|
|
38
|
+
return ""
|
|
39
|
+
query = urllib.parse.urlencode({"server_name": config.server_name})
|
|
40
|
+
try:
|
|
41
|
+
payload = _request("GET", config.report_url + "/api/agent/config?" + query, config.report_token, None)
|
|
42
|
+
fresh = parse_config(_document(config.report_url, config.report_token, payload, config.server_name), config.path)
|
|
43
|
+
except ConfigError as exc:
|
|
44
|
+
return f"同步配置失败:{exc}"
|
|
45
|
+
if dump_config(fresh) == dump_config(config):
|
|
46
|
+
return ""
|
|
47
|
+
write_config(config.path, dump_config(fresh))
|
|
48
|
+
config.account_email = fresh.account_email
|
|
49
|
+
config.agree_tos = fresh.agree_tos
|
|
50
|
+
config.organization = fresh.organization
|
|
51
|
+
config.organization_unit = fresh.organization_unit
|
|
52
|
+
config.webroot = fresh.webroot
|
|
53
|
+
config.staging = fresh.staging
|
|
54
|
+
config.server_name = fresh.server_name
|
|
55
|
+
config.report_url = fresh.report_url
|
|
56
|
+
config.report_token = fresh.report_token
|
|
57
|
+
config.retry_seconds = fresh.retry_seconds
|
|
58
|
+
config.certificates = fresh.certificates
|
|
59
|
+
return "已从服务端更新配置"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _document(server: str, token: str, payload: dict, server_name: str) -> dict:
|
|
63
|
+
retry = payload.get("retry_seconds", 60)
|
|
64
|
+
if isinstance(retry, bool) or not isinstance(retry, int):
|
|
65
|
+
retry = 60
|
|
66
|
+
certificates = payload.get("certificates")
|
|
67
|
+
if not isinstance(certificates, list):
|
|
68
|
+
certificates = []
|
|
69
|
+
return {
|
|
70
|
+
"account_email": payload.get("account_email") or "",
|
|
71
|
+
"agree_tos": bool(payload.get("agree_tos")),
|
|
72
|
+
"organization": payload.get("organization") or "",
|
|
73
|
+
"organization_unit": payload.get("organization_unit") or "",
|
|
74
|
+
"webroot": payload.get("webroot") or "/var/www/acme",
|
|
75
|
+
"staging": False,
|
|
76
|
+
"server_name": payload.get("server_name") or server_name,
|
|
77
|
+
"report_url": server,
|
|
78
|
+
"report_token": token,
|
|
79
|
+
"retry_seconds": retry,
|
|
80
|
+
"certificates": certificates,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _server(value: str) -> str:
|
|
85
|
+
root = value.strip().rstrip("/")
|
|
86
|
+
if not (root.startswith("http://") or root.startswith("https://")):
|
|
87
|
+
raise ConfigError("服务端地址必须以 http:// 或 https:// 开头")
|
|
88
|
+
return root
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _token(value: str) -> str:
|
|
92
|
+
token = value.strip()
|
|
93
|
+
if not token or any(char.isspace() for char in token):
|
|
94
|
+
raise ConfigError("令牌不能为空,也不能含空白")
|
|
95
|
+
return token
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _request(method: str, url: str, token: str, body: dict | None) -> dict:
|
|
99
|
+
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
100
|
+
request = urllib.request.Request(url, data=data, method=method)
|
|
101
|
+
request.add_header("Authorization", "Bearer " + token)
|
|
102
|
+
request.add_header("Accept", "application/json")
|
|
103
|
+
if data is not None:
|
|
104
|
+
request.add_header("Content-Type", "application/json")
|
|
105
|
+
try:
|
|
106
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
107
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
108
|
+
status = response.status
|
|
109
|
+
except urllib.error.HTTPError as exc:
|
|
110
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
111
|
+
message = _detail(detail) or f"服务端返回 {exc.code}"
|
|
112
|
+
raise ConfigError(message) from exc
|
|
113
|
+
except OSError as exc:
|
|
114
|
+
raise ConfigError(f"连不上服务端:{exc}") from exc
|
|
115
|
+
if status >= 400:
|
|
116
|
+
raise ConfigError(f"服务端返回 {status}")
|
|
117
|
+
try:
|
|
118
|
+
payload = json.loads(raw) if raw else {}
|
|
119
|
+
except json.JSONDecodeError as exc:
|
|
120
|
+
raise ConfigError("服务端返回不是 JSON") from exc
|
|
121
|
+
if not isinstance(payload, dict):
|
|
122
|
+
raise ConfigError("服务端返回不是 JSON 对象")
|
|
123
|
+
return payload
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _detail(raw: str) -> str:
|
|
127
|
+
try:
|
|
128
|
+
payload = json.loads(raw)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
return raw.strip()[:200]
|
|
131
|
+
if isinstance(payload, dict):
|
|
132
|
+
detail = payload.get("detail") or payload.get("message") or ""
|
|
133
|
+
return str(detail)[:200]
|
|
134
|
+
return ""
|
hipp_agent/render.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""拼出 certbot 命令,并生成贴进现有 Nginx 的片段。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from hipp_agent.config import AppConfig, CertificateSpec, certificate_email
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def certbot_issue_args(config: AppConfig, spec: CertificateSpec, certbot_bin: str) -> list[str]:
|
|
12
|
+
command = [
|
|
13
|
+
certbot_bin,
|
|
14
|
+
"certonly",
|
|
15
|
+
"--webroot",
|
|
16
|
+
"--webroot-path",
|
|
17
|
+
config.webroot,
|
|
18
|
+
"--cert-name",
|
|
19
|
+
spec.name,
|
|
20
|
+
"--email",
|
|
21
|
+
certificate_email(config, spec),
|
|
22
|
+
"--agree-tos",
|
|
23
|
+
"--non-interactive",
|
|
24
|
+
"--keep-until-expiring",
|
|
25
|
+
]
|
|
26
|
+
if config.staging:
|
|
27
|
+
command.append("--staging")
|
|
28
|
+
for domain in spec.domains:
|
|
29
|
+
command.extend(["--domain", domain])
|
|
30
|
+
return command
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_command(command: list[str]) -> str:
|
|
34
|
+
return shlex.join(command)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_acme_snippet(webroot: str) -> str:
|
|
38
|
+
return (
|
|
39
|
+
"# 放到正在应答该域名 80 端口的 server 内,并位于 return 301 之前。\n"
|
|
40
|
+
"# 不要把本文件以 .conf 放到 conf.d,避免被 http 直接加载。\n"
|
|
41
|
+
"location ^~ /.well-known/acme-challenge/ {\n"
|
|
42
|
+
' default_type "text/plain";\n'
|
|
43
|
+
f" root {webroot};\n"
|
|
44
|
+
" try_files $uri =404;\n"
|
|
45
|
+
" error_page 404 = @hipp_acme_missing;\n"
|
|
46
|
+
"}\n"
|
|
47
|
+
"location @hipp_acme_missing {\n"
|
|
48
|
+
' default_type "text/plain";\n'
|
|
49
|
+
" error_page 405 =405;\n"
|
|
50
|
+
' return 404 "not found\\n";\n'
|
|
51
|
+
"}\n"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def challenge_help(webroot: str, domains: list[str]) -> str:
|
|
56
|
+
"""给操作者看的配置说明。程序不根据这段说明去改 Nginx。"""
|
|
57
|
+
names = "、".join(domains)
|
|
58
|
+
return (
|
|
59
|
+
"等待 .well-known/acme-challenge\n\n"
|
|
60
|
+
f"证书端会探测 http://<域名>/.well-known/acme-challenge/<令牌>。当前域名:{names}。\n"
|
|
61
|
+
"在读到探测文件之前,不会向证书机构申请,也不会修改 Nginx。\n\n"
|
|
62
|
+
"请自行把下面片段放进正在应答该域名 80 端口的 server,位于任何 return 301 之前。\n"
|
|
63
|
+
"不要把片段以 .conf 放到 conf.d,避免被 http 直接加载。\n\n"
|
|
64
|
+
f"{render_acme_snippet(webroot)}\n"
|
|
65
|
+
"然后执行:nginx -t && systemctl reload nginx\n"
|
|
66
|
+
"校验通过后,证书端才会继续申请、生效和监听。"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render_tls_snippet(spec: CertificateSpec) -> str:
|
|
71
|
+
lines = [
|
|
72
|
+
f"# {spec.name} 使用固定文件名。续期只覆盖这两个文件,再重新加载 Nginx,不重启进程。",
|
|
73
|
+
]
|
|
74
|
+
if spec.tls == "enable":
|
|
75
|
+
lines.append("# 放进现有 server。不要加 return 301,不要加 HSTS。")
|
|
76
|
+
lines.append("listen 443 ssl http2;")
|
|
77
|
+
else:
|
|
78
|
+
lines.append("# 用下面两行替换现有 ssl_certificate 与 ssl_certificate_key。不要改 HSTS,不要新增 listen。")
|
|
79
|
+
lines.append(f"ssl_certificate {spec.cert_file};")
|
|
80
|
+
lines.append(f"ssl_certificate_key {spec.key_file};")
|
|
81
|
+
if spec.tls == "enable":
|
|
82
|
+
lines.append("ssl_protocols TLSv1.2 TLSv1.3;")
|
|
83
|
+
return "\n".join(lines) + "\n"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_snippets(config: AppConfig, out_dir: Path) -> list[Path]:
|
|
87
|
+
"""只写校验说明。HTTPS 和监听端口由用户自己配置,这里不生成。"""
|
|
88
|
+
nginx_dir = out_dir / "nginx"
|
|
89
|
+
nginx_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
written: list[Path] = []
|
|
91
|
+
for spec in config.certificates:
|
|
92
|
+
acme = nginx_dir / f"{spec.name}.acme.inc"
|
|
93
|
+
acme.write_text(render_acme_snippet(config.webroot), encoding="utf-8")
|
|
94
|
+
written.append(acme)
|
|
95
|
+
return written
|
hipp_agent/report.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""把本机证书状态送到状态页。正文只有路径和到期时间。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
from hipp_agent.config import AppConfig
|
|
12
|
+
from hipp_agent.install import PublishResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def server_label(config: AppConfig) -> str:
|
|
16
|
+
if config.server_name:
|
|
17
|
+
return config.server_name
|
|
18
|
+
return socket.gethostname() or "未命名"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def push_report(config: AppConfig, results: list[PublishResult], agent_status: str = "running") -> str:
|
|
22
|
+
if not config.report_url:
|
|
23
|
+
return "未配置 report_url,跳过上报"
|
|
24
|
+
payload = {
|
|
25
|
+
"server_name": server_label(config),
|
|
26
|
+
"agent_status": agent_status,
|
|
27
|
+
"pid": os.getpid(),
|
|
28
|
+
"certificates": [
|
|
29
|
+
{
|
|
30
|
+
"name": item.name,
|
|
31
|
+
"domains": item.domains,
|
|
32
|
+
"not_before": item.not_before,
|
|
33
|
+
"not_after": item.not_after,
|
|
34
|
+
"issuer": item.issuer,
|
|
35
|
+
"cert_file": item.cert_file,
|
|
36
|
+
"key_file": item.key_file,
|
|
37
|
+
"reload_status": item.status if item.status != "pending" else "failed",
|
|
38
|
+
"detail": item.detail,
|
|
39
|
+
"stage": item.stage,
|
|
40
|
+
"validity_days": item.validity_days,
|
|
41
|
+
"effective_at": item.effective_at,
|
|
42
|
+
"help": item.help,
|
|
43
|
+
"archive_path": item.archive_path,
|
|
44
|
+
"previous_not_before": item.previous_not_before,
|
|
45
|
+
"previous_not_after": item.previous_not_after,
|
|
46
|
+
"previous_issuer": item.previous_issuer,
|
|
47
|
+
}
|
|
48
|
+
for item in results
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
52
|
+
request = urllib.request.Request(
|
|
53
|
+
config.report_url + "/api/report",
|
|
54
|
+
data=body,
|
|
55
|
+
method="POST",
|
|
56
|
+
)
|
|
57
|
+
request.add_header("Authorization", "Bearer " + config.report_token)
|
|
58
|
+
request.add_header("Content-Type", "application/json")
|
|
59
|
+
try:
|
|
60
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
61
|
+
return f"已上报({response.status})"
|
|
62
|
+
except urllib.error.HTTPError as exc:
|
|
63
|
+
return f"上报失败({exc.code})"
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
return f"上报失败:{exc}"
|