Metadata-Version: 2.4
Name: pyipgate
Version: 1.0.0
Summary: 基于 IP 证书的 HTTPS 端口映射网关：把本机 HTTP 服务一键发布成公网 HTTPS 端口，证书直接签给 IP 地址本身，不需要域名
Keywords: https,reverse-proxy,tls,acme,caddy,ip-certificate
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: POSIX :: Linux
Classifier: Topic :: Internet :: Proxy Servers
Classifier: Topic :: System :: Networking
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# ipgate — 基于 IP 证书的 HTTPS 端口映射网关

把本机任意 HTTP 服务（`127.0.0.1:8080`、`0.0.0.0:3000` …）一键发布成公网 HTTPS
端口，证书是 Let's Encrypt 直接签给 **IP 地址本身**的短期证书，不需要域名。

```
https://115.190.165.92:8443   ->  127.0.0.1:8080
https://115.190.165.92:9444   ->  0.0.0.0:3000
https://115.190.165.92:10443  ->  /var/www/docs   (静态目录)
```

Caddy 负责 TLS 终止与反向代理，Python 负责证书生命周期、路由管理、Web 面板和 CLI。
纯 stdlib，无 pip 依赖。

---

## 架构

```
        公网
          │  https://IP:8443, :9444, :443 …
          ▼
   ┌──────────────┐   admin API (127.0.0.1:2019)   ┌────────────────┐
   │    Caddy     │ ◀───────────────────────────── │  ipgate (py)   │
   │ TLS + 反代    │                                │  面板 + 续期    │
   └──────┬───────┘                                └───────┬────────┘
          │ 读证书文件                                       │ 调用
          ▼                                                ▼
   <pyproxy>/tls/{fullchain,key}.pem              ◀──  acme.sh (tls-alpn-01)
                    ▲
                    └── 同一份证书，pyproxy 也在用
```

## 为什么不让 Caddy 自己签证书

Let's Encrypt 的 IP 证书只在 `shortlived` profile（约 6.5 天）下签发，
Caddy 至今没有实现这条路径（[caddyserver/caddy#7399][1]），会直接报
`subject '<ip>' cannot have public IP certificate`。

所以签发继续交给 **acme.sh**，Caddy 只负责加载证书文件。配置里全程
`automatic_https.disable = true`，避免它徒劳地尝试再报错。

[1]: https://github.com/caddyserver/caddy/issues/7399

## 和 pyproxy 的共存：443 端口之争

同机的 pyproxy 用 `--alpn`（tls-alpn-01）续期，需要**独占 443**。
Caddy 常驻 443 会让它签不下来，而证书 6 天就过期 —— 直接把 VPN 的 HTTPS 弄挂。

ipgate 的做法是成为唯一真正动手续期的一方，且**不改 pyproxy 一行代码**：
抢在 acme.sh 自己计划的时刻之前把证书换掉，它跑到时就只会打印
`Skipping. Next renewal time is ...` 然后退出，全程不碰 443。

关键是**怎么知道它计划什么时候动手**。不能靠「剩余天数」猜 —— acme.sh 走
[RFC 9773 ARI][2]，续期时刻是在 Let's Encrypt 下发的 `suggestedWindow` 里
**随机取点**，命令行上的 `--days` 会被整个覆盖：

```sh
# acme.sh 内部
_ari_offset=$(_math "$(_time)" % "$_ari_window")
Le_NextRenewTime=$(_math "$_ari_start_t_new" + "$_ari_offset")
```

实测这台机器上 `Le_NextRenewTime` = notAfter − 3.35 天，而不是 `--days 3`
应该给出的 notAfter − 3 天。

所以 ipgate 直接读它写在共享账本
（`<acme_home>/<IP>_ecc/<IP>.conf` 里的 `Le_NextRenewTime`）中的计划时刻，
**永远比它早 6 小时**动手（`renew_lead` 可调）。ARI 随机到哪都成立，
LE 以后改窗口形状也不用跟着调参数。读不到账本时才退回「剩余不足 4 天」的兜底判断。

即便万一让 pyproxy 抢先跑了一次，后果也只是它 bind 443 失败、在自己的
`status.json` 里记一条错误 —— 证书是共用的，ipgate 这边照常续上，不会真的断服务。

ipgate 调 acme.sh 时带 `--force`（按 acme.sh 自己的账本还没到期），这绕过了它
内建的节流，因此另有一道 `min_issue_interval`（默认 12 小时）兜底，防止逻辑
出错时反复冲击 Let's Encrypt 的配额。

[2]: https://datatracker.ietf.org/doc/rfc9773/

续期发生时，如果确实有映射占着 443，ipgate 会通过 admin API 临时把**这一个**
监听器摘掉，签完立刻挂回来 —— 其余端口全程不受影响，443 的中断窗口约 10 秒，
约每 2.5 天一次。

## 安装

```bash
./install.sh                    # 幂等，可反复执行
ipgate totp                     # 强烈建议：绑定两步验证
```

### 不用 install.sh：pip 安装

```bash
pip install pyipgate
```

`pip install` 只装 Python 代码，不像 `install.sh` 一条命令包办一切，但装完之后
剩下的都能在网页 `http://<IP>:9500` 上点完：

1. 跑一次 `ipgate-server`，打开面板。「日志」页如果显示 Caddy 未安装，会出现一张
   "安装 Caddy"卡片——点一下会下载对应架构的二进制、注册 `caddy.service` 并启动，
   跟 `install.sh` 那段是同一份逻辑，只是触发方式从命令行改成了网页；
   命令行等价操作是 `ipgate caddy install`
2. 「证书」页如果没有可用证书，会出现"初始化证书"卡片：先探测同机是否有 pyproxy
   可复用，探测不到就下载 acme.sh 并签发第一张证书（正式/staging 二选一）；
   命令行等价操作是 `ipgate cert init`（`--dry-run` 先看会做什么，`--staging`
   先在测试环境跑通流程，`--acme-sh <路径>` 复用已有的 acme.sh）
3. 想要开机自启 / 崩溃自动重启 `ipgate-server` 本身（Caddy 那份已经由上面第 1 步
   注册好了），照抄 `install.sh` 里 `ipgate.service` 那段 systemd 单元自己写一份——
   这是目前唯一还没搬上网页的部分，因为 ipgate 没法从自己进程里注册"重启自己"的
   服务

状态目录（`config.json` / `routes.json` / `caddy.json` / `cert-status.json` /
`acme.sh` 本体 / 签发出的证书）默认在 `/etc/ipgate/`（root 权限），可用
`IPGATE_HOME` 环境变量改到别处。

简单说：`install.sh` 是「自动挡」，一条命令连 Caddy 和 systemd 一起装好；
`pip install pyipgate` 是「半自动挡」，Caddy 和证书这两步网页/CLI 都能触发，
只有 `ipgate-server` 自身的开机自启还需要手写一份 systemd 单元。

### 首次设置密码

服务启动时若还没有密码，会开一个**首次设置窗口**（`setup_window`，默认 12 小时），
直接打开面板就能在浏览器里设定，不必 SSH。窗口只存在内存里：

* 设定完成即关闭；
* 超时未设定则锁死，`systemctl restart ipgate` 可重新开窗；
* 已经设过密码的实例重启**不会**再开窗。

不想用窗口的话，`ipgate passwd` 随时可以在命令行设定/修改。

> ⚠️ 窗口期内面板对全网是真的开放的：任何人扫到 `:9500` 都能抢先设定密码、
> 接管这台网关。窗口越长风险越大 —— 12 小时意味着装完就该尽快去把密码设掉，
> 别装完就撂着过夜。改短：`config.json` 里的 `setup_window`（秒）。

安装脚本会下载 Caddy、部署代码（路径由 `IPGATE_DIR` 决定）、**自动探测同机 pyproxy
的证书目录**、注册两个 systemd 单元（`caddy.service` / `ipgate.service`），
并把 CLI 链接为 `/usr/local/bin/ipgate`。

> pyproxy 的安装路径各机器不一：已见过 `/root/vpn/pyproxy` 和
> `/root/main/vpn/pyproxy`。探测按「证书文件最新」挑选，多个备份目录也不会认错。
> 探测不到时在 `config.json` 里手工填 `cert_dir` / `acme_home` / `acme_sh` 即可。

## CLI

```bash
ipgate status                              # 总览：IP / 证书 / Caddy / 所有映射
ipgate ls

ipgate add web 8443 127.0.0.1:8080         # 新增映射
ipgate add api 9444 0.0.0.0:3000 --note "内部 API"
ipgate add sec 10443 127.0.0.1:9443 --tls  # 后端本身是 HTTPS（默认跳过证书校验）
ipgate add-static docs 11443 /var/www/docs # 直接发布一个目录

ipgate edit web --upstream 127.0.0.1:8081
ipgate disable web / enable web / rm web
ipgate test web                            # 探测后端连通性
ipgate reload                              # 重新生成配置并热加载

ipgate cert                                # 证书详情（JSON）
ipgate cert renew                          # 按需续期
ipgate cert renew --force                  # 强制重签（注意每周配额）
ipgate cert init                           # 首次初始化：探测同机 pyproxy，探测不到就下载 acme.sh 并签发
ipgate cert init --dry-run                 # 只打印会做什么，不实际下载/签发/写配置
ipgate cert init --staging                 # 先在 LE 测试环境跑通流程，不消耗正式配额
ipgate cert init --acme-sh /path/to/acme.sh  # 已有 acme.sh 就不用重新下载

ipgate passwd  |  ipgate totp  |  ipgate token
ipgate caddy start|stop|restart|status|config
ipgate caddy install                       # 下载二进制、注册 systemd 单元、启动（幂等）
ipgate caddy install --force               # 已装过也强制重新下载覆盖

ipgate bundle                              # 打包一份可拿去别处部署的 zip
ipgate bundle --with-caddy                 # 连 caddy 二进制一起打（约 17 MB）
```

## 部署到别的机器

管理界面「设置」页有下载按钮，命令行用 `ipgate bundle`。包里是**纯代码**：
管理密码、API token、两步验证密钥、端口映射都不在里面（用白名单挑文件，
不是排除法 —— 漏掉排除项就是泄密）。

目标机器上解压后 `bash install.sh` 即可，脚本会自动探测那台机器上 pyproxy 的
证书目录。

> `--with-caddy` 版把 caddy 二进制一起打进去，install.sh 会直接用、不联网。
> 国内机器直连 GitHub 拉 caddy 常常只有几十 KB/s，这一步能省十几分钟。

所有写操作都会立刻通过 Caddy admin API 热加载，**不断开已有连接**。
Caddy 若拒绝新配置，改动会自动回滚。

## 管理面板

`http://<IP>:9500` —— 端口映射的增删改查、证书状态与手动续期、acme.sh
实时输出、两步验证绑定（扫码）、systemd 日志查看。

登录需要密码 **且**（启用后）动态验证码，登录失败 5 分钟内 8 次即锁定。

> 面板按需求直接监听公网 HTTP 端口，密码在链路上是明文的。
> 想让面板自己也走 HTTPS，加一条指向它的映射即可：
> `ipgate add panel 443 127.0.0.1:9500`，之后用 `https://<IP>/` 访问。

## 文件

| 路径 | 说明 |
|---|---|
| `config.json` | 面板端口、证书路径、续期阈值、首次设置窗口、密码散列、API token（0600） |
| `routes.json` | 端口映射定义 |
| `caddy.json` | 生成的 Caddy 配置，供 systemd 冷启动使用 |
| `cert-status.json` | 最近一次续期的状态与 acme.sh 输出 |
| `totp.json` | 两步验证密钥（0600） |

## 注意

- **端口一对一**：一个对外端口只属于一条映射，新增时会先试探 bind，端口被别的
  进程占用会当场拒绝，而不是等 Caddy 起不来。
- **`0.0.0.0:port` 会自动改写成 `127.0.0.1:port`** —— 从 `ss -tlnp` 抄来的地址
  可以直接粘。
- **WebSocket 无需额外配置**，Caddy 的 `reverse_proxy` 原生支持协议升级。
- **不要用别的工具再给这个 IP 签证书**：同一 SAN 的重复证书 Let's Encrypt
  每周只给 5 张，两个客户端各自续期很容易撞上限。
