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/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}"