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/runner.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""计划、探测、调用 certbot、安装续期钩子。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from hipp_agent.config import (
|
|
17
|
+
AppConfig,
|
|
18
|
+
CertificateSpec,
|
|
19
|
+
ConfigError,
|
|
20
|
+
certificate_agreed,
|
|
21
|
+
certificate_email,
|
|
22
|
+
project_root,
|
|
23
|
+
validate_for_issue,
|
|
24
|
+
)
|
|
25
|
+
from hipp_agent.install import describe_installed, nginx_reload_args, publish_all
|
|
26
|
+
from hipp_agent.render import certbot_issue_args, format_command, write_snippets
|
|
27
|
+
from hipp_agent.report import push_report, server_label
|
|
28
|
+
|
|
29
|
+
HOOK_NAME = "hipp-https-reload-nginx.sh"
|
|
30
|
+
HOOK_DEST = Path("/etc/letsencrypt/renewal-hooks/deploy") / HOOK_NAME
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PreflightError(RuntimeError):
|
|
34
|
+
"""校验地址还不能被证书机构读到。"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class IssueOutcome:
|
|
39
|
+
name: str
|
|
40
|
+
ok: bool
|
|
41
|
+
detail: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def certbot_bin() -> str | None:
|
|
45
|
+
override = os.environ.get("CERTBOT_BIN", "").strip()
|
|
46
|
+
if override:
|
|
47
|
+
return override
|
|
48
|
+
return shutil.which("certbot")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_plan(config: AppConfig, out_dir: Path) -> str:
|
|
52
|
+
binary = certbot_bin() or "certbot"
|
|
53
|
+
lines = [
|
|
54
|
+
f"配置:{config.path}",
|
|
55
|
+
f"webroot:{config.webroot}",
|
|
56
|
+
f"证书客户端:{binary}",
|
|
57
|
+
]
|
|
58
|
+
if certbot_bin() is None and not os.environ.get("CERTBOT_BIN"):
|
|
59
|
+
lines.append("本机 PATH 中没有 certbot。服务器上执行:sudo apt-get install -y certbot")
|
|
60
|
+
if config.staging:
|
|
61
|
+
lines.append("staging 为 true:签出的证书浏览器不信任,不要装到对外 443。")
|
|
62
|
+
if not config.account_email or not config.agree_tos:
|
|
63
|
+
lines.append("邮箱未填或 agree_tos 不是 true。issue --apply 会拒绝,先改 config.json。")
|
|
64
|
+
lines.append(f"本机名称:{server_label(config)}")
|
|
65
|
+
if config.report_url:
|
|
66
|
+
lines.append(f"上报:{config.report_url}/api/report")
|
|
67
|
+
else:
|
|
68
|
+
lines.append("上报:未配置")
|
|
69
|
+
lines.append("证书内容有变化时执行 " + " ".join(nginx_reload_args(True)) + ",不重启 Nginx。")
|
|
70
|
+
lines.append("HTTPS 和 443 端口不由程序配置,请在 Nginx 里自行引用证书文件。")
|
|
71
|
+
lines.append("")
|
|
72
|
+
for spec in config.certificates:
|
|
73
|
+
state = "启用" if spec.enabled else "停用(本机不申请)"
|
|
74
|
+
lines.append(f"[{spec.name}] {state}")
|
|
75
|
+
lines.append(f"证书文件:{spec.cert_file}")
|
|
76
|
+
lines.append(f"私钥文件:{spec.key_file}")
|
|
77
|
+
lines.append(format_command(certbot_issue_args(config, spec, binary)))
|
|
78
|
+
lines.append("")
|
|
79
|
+
written = write_snippets(config, out_dir)
|
|
80
|
+
lines.append("Nginx 片段:")
|
|
81
|
+
lines.extend(str(path) for path in written)
|
|
82
|
+
lines.append("")
|
|
83
|
+
lines.append("未向证书机构申请。服务器步骤见 doc/99-ops/run.md。")
|
|
84
|
+
return "\n".join(lines) + "\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def issue(config: AppConfig, apply: bool, only: set[str] | None = None) -> tuple[int, str]:
|
|
88
|
+
binary = certbot_bin() or "certbot"
|
|
89
|
+
if not apply:
|
|
90
|
+
commands = [
|
|
91
|
+
format_command(certbot_issue_args(config, spec, binary))
|
|
92
|
+
for spec in config.certificates
|
|
93
|
+
if spec.enabled and (only is None or spec.name in only)
|
|
94
|
+
]
|
|
95
|
+
text = "未加 --apply,未调用 certbot。\n" + "\n".join(commands) + "\n"
|
|
96
|
+
return 0, text
|
|
97
|
+
|
|
98
|
+
validate_for_issue(config)
|
|
99
|
+
_require_root()
|
|
100
|
+
if certbot_bin() is None:
|
|
101
|
+
return 127, "找不到 certbot。先执行:sudo apt-get install -y certbot\n"
|
|
102
|
+
|
|
103
|
+
webroot = Path(config.webroot)
|
|
104
|
+
webroot.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
outcomes: list[IssueOutcome] = []
|
|
106
|
+
issued: list[str] = []
|
|
107
|
+
for spec in config.certificates:
|
|
108
|
+
if not spec.enabled:
|
|
109
|
+
outcomes.append(IssueOutcome(spec.name, True, "已停用,跳过"))
|
|
110
|
+
continue
|
|
111
|
+
if only is not None and spec.name not in only:
|
|
112
|
+
continue
|
|
113
|
+
if not certificate_agreed(config, spec):
|
|
114
|
+
outcomes.append(IssueOutcome(spec.name, False, "所选业务配置未同意服务条款"))
|
|
115
|
+
continue
|
|
116
|
+
if "@" not in certificate_email(config, spec):
|
|
117
|
+
outcomes.append(IssueOutcome(spec.name, False, "所选业务配置未填写账户邮箱"))
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
for domain in spec.domains:
|
|
121
|
+
preflight(domain, webroot)
|
|
122
|
+
except PreflightError as exc:
|
|
123
|
+
outcomes.append(IssueOutcome(spec.name, False, str(exc)))
|
|
124
|
+
continue
|
|
125
|
+
command = certbot_issue_args(config, spec, binary)
|
|
126
|
+
completed = subprocess.run(command, check=False)
|
|
127
|
+
if completed.returncode == 0:
|
|
128
|
+
issued.append(spec.name)
|
|
129
|
+
outcomes.append(IssueOutcome(spec.name, True, "certbot 完成"))
|
|
130
|
+
else:
|
|
131
|
+
outcomes.append(IssueOutcome(spec.name, False, f"certbot 退出码 {completed.returncode}"))
|
|
132
|
+
|
|
133
|
+
lines = [f"{item.name}:{'成功' if item.ok else '失败'}。{item.detail}" for item in outcomes]
|
|
134
|
+
if issued and config.staging:
|
|
135
|
+
lines.append("演练证书未写入 Nginx 路径。")
|
|
136
|
+
elif issued:
|
|
137
|
+
code, text = deploy(config, apply=True, only=set(issued))
|
|
138
|
+
lines.append(text.rstrip())
|
|
139
|
+
if code != 0:
|
|
140
|
+
return code, "\n".join(lines) + "\n"
|
|
141
|
+
failed = [item for item in outcomes if not item.ok]
|
|
142
|
+
if not failed and issued and not config.staging:
|
|
143
|
+
lines.append("若 Nginx 还未引用上述证书文件,请自行配置 HTTPS 和端口后再加载。")
|
|
144
|
+
return (1 if failed else 0), "\n".join(lines) + "\n"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def deploy(config: AppConfig, apply: bool, only: set[str] | None = None) -> tuple[int, str]:
|
|
148
|
+
if not apply:
|
|
149
|
+
lines = ["未加 --apply,未写证书,也未重新加载 Nginx。"]
|
|
150
|
+
for spec in config.certificates:
|
|
151
|
+
if not spec.enabled:
|
|
152
|
+
continue
|
|
153
|
+
if only is not None and spec.name not in only:
|
|
154
|
+
continue
|
|
155
|
+
lines.append(f"{spec.name} -> {spec.cert_file}")
|
|
156
|
+
lines.append("内容有变化时:" + " ".join(nginx_reload_args(True)))
|
|
157
|
+
return 0, "\n".join(lines) + "\n"
|
|
158
|
+
if config.staging:
|
|
159
|
+
return 2, "演练证书不写入 Nginx 使用的路径。\n"
|
|
160
|
+
_require_root()
|
|
161
|
+
try:
|
|
162
|
+
results = publish_all(config, only=only)
|
|
163
|
+
except ConfigError as exc:
|
|
164
|
+
return 2, str(exc) + "\n"
|
|
165
|
+
lines = [f"{item.name}:{item.status}。{item.detail}".rstrip("。") for item in results]
|
|
166
|
+
lines.append(push_report(config, results))
|
|
167
|
+
failed = [item for item in results if item.status in ("failed", "missing")]
|
|
168
|
+
return (1 if failed else 0), "\n".join(lines) + "\n"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def report(config: AppConfig, apply: bool) -> tuple[int, str]:
|
|
172
|
+
if not apply:
|
|
173
|
+
return 0, "未加 --apply,未上报。\n" + server_label(config) + "\n"
|
|
174
|
+
results = describe_installed(config)
|
|
175
|
+
text = push_report(config, results)
|
|
176
|
+
missing = [item.name for item in results if item.status == "missing"]
|
|
177
|
+
if missing:
|
|
178
|
+
return 1, text + "\n缺少证书文件:" + "、".join(missing) + "\n"
|
|
179
|
+
return 0, text + "\n"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def renew(config: AppConfig, apply: bool, dry_run: bool) -> tuple[int, str]:
|
|
183
|
+
binary = certbot_bin() or "certbot"
|
|
184
|
+
command = [binary, "renew"]
|
|
185
|
+
if dry_run:
|
|
186
|
+
command.append("--dry-run")
|
|
187
|
+
if not apply:
|
|
188
|
+
return 0, "未加 --apply,未调用 certbot。\n" + format_command(command) + "\n"
|
|
189
|
+
_require_root()
|
|
190
|
+
if certbot_bin() is None:
|
|
191
|
+
return 127, "找不到 certbot。先执行:sudo apt-get install -y certbot\n"
|
|
192
|
+
completed = subprocess.run(command, check=False)
|
|
193
|
+
if completed.returncode != 0:
|
|
194
|
+
return completed.returncode, f"certbot renew 退出码 {completed.returncode}\n"
|
|
195
|
+
if dry_run:
|
|
196
|
+
return 0, "certbot renew --dry-run 完成。未写入 Nginx 路径。\n"
|
|
197
|
+
code, text = deploy(config, apply=True)
|
|
198
|
+
return code, "certbot renew 完成。\n" + text
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def install_hook(config: AppConfig, apply: bool) -> tuple[int, str]:
|
|
202
|
+
script = _hook_script(config.path)
|
|
203
|
+
if not apply:
|
|
204
|
+
return 0, "未加 --apply,未写入钩子。\n将安装到 " + str(HOOK_DEST) + "\n" + script
|
|
205
|
+
if config.path.name == "config.example.json":
|
|
206
|
+
return 2, "请先把 config.example.json 复制为 config.json,再安装钩子。\n"
|
|
207
|
+
_require_root()
|
|
208
|
+
HOOK_DEST.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
HOOK_DEST.write_text(script, encoding="utf-8")
|
|
210
|
+
HOOK_DEST.chmod(0o755)
|
|
211
|
+
return 0, f"已安装 {HOOK_DEST}\n启用定时续期:sudo systemctl enable --now certbot.timer\n"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _hook_script(config_path: Path) -> str:
|
|
215
|
+
root = shlex.quote(str(project_root()))
|
|
216
|
+
python = shlex.quote(sys.executable)
|
|
217
|
+
config = shlex.quote(str(config_path.resolve()))
|
|
218
|
+
return (
|
|
219
|
+
"#!/bin/sh\n"
|
|
220
|
+
"set -eu\n"
|
|
221
|
+
f"cd {root}\n"
|
|
222
|
+
f"exec {python} -m hipp_agent --config {config} deploy --apply\n"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def preflight(domain: str, webroot: Path, timeout: float = 10.0) -> None:
|
|
227
|
+
challenge_dir = webroot / ".well-known" / "acme-challenge"
|
|
228
|
+
challenge_dir.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
token = "hipp-https-" + secrets.token_hex(8)
|
|
230
|
+
body = secrets.token_hex(16)
|
|
231
|
+
path = challenge_dir / token
|
|
232
|
+
path.write_text(body, encoding="ascii")
|
|
233
|
+
try:
|
|
234
|
+
status, payload, location = _fetch_no_redirect(
|
|
235
|
+
f"http://{domain}/.well-known/acme-challenge/{token}",
|
|
236
|
+
timeout,
|
|
237
|
+
)
|
|
238
|
+
except OSError as exc:
|
|
239
|
+
raise PreflightError(f"{domain} 的校验地址无法连接:{exc}") from exc
|
|
240
|
+
finally:
|
|
241
|
+
path.unlink(missing_ok=True)
|
|
242
|
+
if status == 200 and payload == body:
|
|
243
|
+
return
|
|
244
|
+
extra = f",Location: {location}" if location else ""
|
|
245
|
+
raise PreflightError(
|
|
246
|
+
f"{domain} 的校验地址返回 {status}{extra},没有读到探测文件。"
|
|
247
|
+
"等待 .well-known/acme-challenge。请在状态页打开配置帮助,自行改好 Nginx。"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _fetch_no_redirect(url: str, timeout: float) -> tuple[int, str, str | None]:
|
|
252
|
+
parts = urllib.parse.urlsplit(url)
|
|
253
|
+
connection = http.client.HTTPConnection(parts.hostname, parts.port or 80, timeout=timeout)
|
|
254
|
+
try:
|
|
255
|
+
connection.request("GET", parts.path or "/")
|
|
256
|
+
response = connection.getresponse()
|
|
257
|
+
raw = response.read(4096)
|
|
258
|
+
text = raw.decode("utf-8", errors="replace").strip()
|
|
259
|
+
return response.status, text, response.getheader("Location")
|
|
260
|
+
finally:
|
|
261
|
+
connection.close()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _require_root() -> None:
|
|
265
|
+
if os.geteuid() != 0:
|
|
266
|
+
raise PermissionError("申请、续期和安装续期钩子需要 root。")
|
|
@@ -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,15 @@
|
|
|
1
|
+
hipp_agent/__init__.py,sha256=FS-AC5MjbrgosCNu2py7orZB69iY69Ro4lUHnaFnGO4,102
|
|
2
|
+
hipp_agent/__main__.py,sha256=TK4fT3W8XKhSDiHf4x4ls4asVeeFtLVYZwJnwaDa4c4,3863
|
|
3
|
+
hipp_agent/certinfo.py,sha256=wtTqtDBvRuEF6nBkvn0p5xTjEq9v1j3UgSF-GukI4yQ,1419
|
|
4
|
+
hipp_agent/config.py,sha256=Rof8FQEsn1e0rc6UdLfsJFqORgbR_9JDIEJSt7QSEko,10699
|
|
5
|
+
hipp_agent/daemon.py,sha256=YK_WlxVOqky2szLwirzMiLJWyhmm8kTQHqxijHwuvu8,7751
|
|
6
|
+
hipp_agent/install.py,sha256=MJcrQ7V216SudzxuzLYAH-dK1jDoFrQqFXTQSYodxZ4,8320
|
|
7
|
+
hipp_agent/register.py,sha256=FoP4CVrdcBppN_1LbOjpw3yekK1PjYNGt9LkTxcW4zk,5230
|
|
8
|
+
hipp_agent/render.py,sha256=H-rRziW_1w4G2x_578nkgMId9IlPqqJn-dDsxsWWXvY,3635
|
|
9
|
+
hipp_agent/report.py,sha256=JOkgqiLL_8bqZRVuacRjbKi5gTbygfLc-O1RkNk5wCM,2307
|
|
10
|
+
hipp_agent/runner.py,sha256=qLcKWr8eak0yQUaZUeRE-qh-lYiyeHbVV7jsvfcjzy0,10596
|
|
11
|
+
hipp_https_agent-0.2.0.dist-info/METADATA,sha256=8LeM6Xt1CZTmO6ilstI6-xvYictc0MTArKwBU197ALQ,2796
|
|
12
|
+
hipp_https_agent-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
hipp_https_agent-0.2.0.dist-info/entry_points.txt,sha256=nJj7QyFLH_FT9ciFyjjITiqrLZSsN4ylB7IILOLr3VE,56
|
|
14
|
+
hipp_https_agent-0.2.0.dist-info/top_level.txt,sha256=kkb29UBJy4dUEchZcjrdk3Eunk6yYtMMmPNtIBvudq4,11
|
|
15
|
+
hipp_https_agent-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hipp_agent
|