diff --git a/Makefile b/Makefile index 16c1f06..7a5a7db 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ # make dmg 打包 macOS 安装镜像 dist/PortRelay-<版本>.dmg(UNIVERSAL=1 构建通用版本) # make release 打包服务端(Linux)与命令行 Agent(Linux / Windows)到 dist/ # make snapshots 离屏渲染 App 界面截图(tools/ui-snapshots/out) +# make readme-images 重新生成 README 的全部截图和动图到 docs/images(需要 Google Chrome) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo dev) LDFLAGS := -s -w -X main.version=$(VERSION) @@ -16,7 +17,7 @@ GOBUILD := CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" SERVER_PLATFORMS := linux/amd64 linux/arm64 AGENT_PLATFORMS := linux/amd64 linux/arm64 windows/amd64 -.PHONY: all test fmt server agent app install-app dmg release snapshots clean +.PHONY: all test fmt server agent app install-app dmg release snapshots readme-images clean all: test agent @@ -80,5 +81,8 @@ release: snapshots: tools/ui-snapshots/render.sh +readme-images: + tools/ui-snapshots/readme.sh + clean: rm -rf bin build dist PortRelay.app mac-native/PortRelay tools/ui-snapshots/.build tools/ui-snapshots/out diff --git a/README.md b/README.md index 9a4394f..b3892d4 100644 --- a/README.md +++ b/README.md @@ -51,18 +51,18 @@ PortRelay 把这件事变成三步: **访问控制** - 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。 -- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、直到通道关闭,或自定义),以及是否需要审批。 +- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、通道开启期间,或自定义),以及是否需要审批。 - 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。 - **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。 - 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。 -- **申请延长**:访问结束后 2 小时内(通道已关闭也可以),同事重新打开原链接点「申请延长」;你在 App、菜单栏或网页面板里选择延长多久即批准,关闭的通道会自动重新开启。你也可以对已授权或刚结束的设备主动延长。 +- **申请延长**:同事重新打开原来的邀请链接,点「申请延长」,你在 App、菜单栏或网页面板里选好延长多久即可批准。授权还没到期时,从原到期时间往后顺延;已经结束的从批准时算起,结束后 2 小时内都能申请,通道关了也没关系,批准后会自动重新开启。被撤销的授权不能延长。你也可以主动给已授权或刚结束的设备延长。 - **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。 **通道生命周期** - **闲置自动关闭**:没有连接、没有有效授权、没有待处理申请,也没有还能使用的邀请链接时开始计时,默认 30 分钟后关闭。 -- 可选的**最长开启时长**,到点无论是否在用都关闭。 -- 通道组内任一地址在用,整组都不算闲置,一起开关、一起关闭。 +- 可选的**最长开启时长**,到点无论是否在用都关闭。批准延长时,如果新的授权到期时间更晚,通道会开到授权结束。 +- 通道组的闲置按整组计算:任一地址在用,整组都不会因闲置关闭,到时也是整组一起关。每个地址仍然可以单独开关。 - 记录首次连接时间、实际连接时长和每次关闭的原因。 - 编辑通道不会打断现有连接;更换端口时先绑定新端口再释放旧端口。 @@ -104,13 +104,23 @@ PortRelay 把这件事变成三步: +授权快到期或刚结束时,同事再打开同一个链接,就能看到自己的授权状态并申请延长。你选好时长批准后,页面会显示新的到期时间。 + +
![]() ④ 再次打开链接,申请延长 |
+ ![]() ⑤ 等待管理员选择时长 |
+ ![]() ⑥ 延长已批准 |
+
@@ -210,10 +220,10 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
### 3. 创建通道并邀请同事
1. 点 **添加通道**,填写名称和目标主机、端口(如 `10.0.1.16`、`3306`)。可以先测试一下本机能否连通;网关上的访问端口自动分配。
-2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
+2. 访问方式保持默认的 **仅授权链接**。保存后会直接弹出邀请设置,选择授权后可访问时长、可用设备数,以及是否需要审批。以后要再发邀请,点通道卡片上的 **邀请**。
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
-需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。
+需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后弹出的邀请对整组有效。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。
## 配置参考
@@ -299,6 +309,7 @@ make agent # 编译当前平台的 Agent 到 bin/
make app # 构建 PortRelay.app(UNIVERSAL=1 ./build-app.sh 构建通用二进制)
make release # 打包全部平台的网关与 Agent 到 dist/
make snapshots # 离屏渲染 App 各界面截图到 tools/ui-snapshots/out/
+make readme-images # 重新生成 README 的全部截图和动图到 docs/images/
```
```text
@@ -309,10 +320,10 @@ client/ Agent:配置、隧道、本地 API、闲置关闭、流
core/ 协议消息、ACL、流量计量与限速
mac-native/ SwiftUI 菜单栏 App
deploy/ systemd、Docker Compose 部署示例与安装脚本
-tools/ui-snapshots/ 离屏截图工具与 README 动图生成(mkgif)
+tools/ui-snapshots/ README 截图与动图:App 离屏渲染、网页截图、GIF 合成
```
-README 里的截图和动图都用模拟数据生成:`tools/ui-snapshots/render.sh readme` 渲染截图,`FRAMES=40 tools/ui-snapshots/render.sh gif` 渲染逐帧画面,再用 `go run ./tools/ui-snapshots/mkgif` 合成 GIF。
+README 里的截图和动图都使用模拟数据,改了界面后运行 `make readme-images` 即可全部重新生成(需要 Google Chrome)。App 界面离屏渲染,动图由逐帧画面合成,授权页和网页面板用无头 Chrome 截图。
欢迎提交 Issue 和 Pull Request。提交前请先运行 `make test`。
diff --git a/README_EN.md b/README_EN.md
index 1a278bf..6569560 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -54,18 +54,18 @@ PortRelay turns that into three steps:
**Access control**
- Three access modes: **invite-only** (the default and safest), an IP / CIDR allowlist (which can be combined with invites), and open to all.
-- Each invite link has its own expiry, device limit, access duration once granted (30 min, 1 h, 2 h, 8 h, until end of day, until the tunnel closes, or a custom length) and an optional approval step.
+- Each invite link has its own expiry, device limit, access duration once granted (30 min, 1 h, 2 h, 8 h, until end of day, while the tunnel stays open, or a custom length) and an optional approval step.
- When approval is required, the app shows a system notification and you approve or deny with one click. The requester's page refreshes on its own.
- **The access clock starts at the first connection**, so a teammate can accept an invite now and use it later.
- When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged.
-- **Extensions.** Within two hours after access ends (even if the tunnel has been closed), a teammate reopens the original link and clicks 申请延长 (Request extension). You approve it by picking a duration in the app, the menu bar or the web panel, and a closed tunnel reopens on its own. You can also extend a running or just-ended grant yourself.
+- **Extensions.** A teammate reopens the original invite link and clicks 申请延长 (Request extension); you approve by picking a duration in the app, the menu bar or the web panel. A grant that is still running is extended from its current expiry. One that has ended starts again from the moment you approve; that works for up to two hours after it ended, even if the tunnel has been closed, and the tunnel reopens on approval. Revoked grants cannot be extended. You can also extend a running or just-ended grant yourself.
- **Tunnel groups**: the add-tunnel form takes a list of targets, one host per row with comma-separated ports (a pasted list of IPs is split into rows), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere.
**Tunnel lifecycle**
- **Idle auto-close.** The idle timer starts when a tunnel has no connections, no active grants, no pending requests and no invite links with uses left. By default the tunnel closes after 30 minutes of idle time.
-- An optional **maximum open time** closes the tunnel at a fixed point, whether or not it is in use.
-- While any address in a group is in use, the whole group counts as in use; members are switched on and off together.
+- An optional **maximum open time** closes the tunnel at a fixed point, whether or not it is in use. If you approve an extension that runs past that point, the tunnel stays open until the grant ends.
+- Idle time is counted per group: while any address is in use, none of them is closed for being idle, and when the group does go idle it closes as a whole. Each address can still be switched on or off on its own.
- PortRelay records the first-connection time, the actual connected time and why each tunnel was closed.
- Editing a tunnel keeps existing connections. Moving a tunnel binds the new port before releasing the old one.
@@ -107,13 +107,23 @@ PortRelay turns that into three steps:
+When access is about to run out or has just ended, the teammate opens the same link again, sees where their access stands and asks for more time. Once you pick a duration, the page shows the new expiry.
+
+
![]() ④ Reopen the link and request an extension |
+ ![]() ⑤ Wait while you choose a duration |
+ ![]() ⑥ Extension approved |
+
@@ -219,10 +229,10 @@ Open ⚙️ Settings and fill in:
### 3. Create a tunnel and invite someone
1. Click **添加通道** (Add tunnel) and enter a name, a target host and port such as `10.0.1.16` and `3306`. You can probe the target from your machine first; the gateway port is assigned automatically.
-2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required.
+2. Keep the default access mode, **仅授权链接** (invite-only). After you save, the invite form opens: choose how long access lasts, how many devices may use the link, and whether approval is required. To send another invite later, click **邀请** (Invite) on the tunnel card.
3. Send the generated message or QR code. Once the teammate has entered their name, they can connect their database client or SSH to `192.168.1.10:33660`.
-To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form with one host per row (comma-separated ports; you can paste a list of IPs). The form previews the port mapping and puts the addresses in a new tunnel group; idle close, bandwidth and access settings apply to every address. Then click **邀请整组** (Invite group). To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form. A group's connections and history can be viewed by device or by address.
+To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form with one host per row (comma-separated ports; you can paste a list of IPs). The form previews the port mapping and puts the addresses in a new tunnel group; idle close, bandwidth and access settings apply to every address. The invite form that opens after creation covers the whole group. To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form. A group's connections and history can be viewed by device or by address.
## Configuration
@@ -315,6 +325,7 @@ make agent # build the agent for this platform into bin/
make app # build PortRelay.app (UNIVERSAL=1 ./build-app.sh for a universal binary)
make release # package gateway + agent for all platforms into dist/
make snapshots # render app screens offscreen into tools/ui-snapshots/out/
+make readme-images # regenerate every README screenshot and animation in docs/images/
```
```text
@@ -325,13 +336,10 @@ client/ agent: config, tunnels, local API, idle close, traffic st
core/ protocol messages, ACL, traffic metering and rate limiting
mac-native/ SwiftUI menu-bar app
deploy/ systemd and Docker Compose examples, install script
-tools/ui-snapshots/ offscreen screenshot renderer and README GIF builder (mkgif)
+tools/ui-snapshots/ README images: offscreen app rendering, web screenshots, GIF encoding
```
-All screenshots and animations in this README come from mock data:
-- `tools/ui-snapshots/render.sh readme` renders the screenshots;
-- `FRAMES=40 tools/ui-snapshots/render.sh gif` renders the animation frames;
-- `go run ./tools/ui-snapshots/mkgif` encodes the frames as a GIF.
+All screenshots and animations in this README use mock data. After a UI change, run `make readme-images` to regenerate them (Google Chrome required). App screens are rendered offscreen, the animation is encoded from rendered frames, and the invite pages and web panel are captured with headless Chrome.
Issues and pull requests are welcome. Please run `make test` before submitting.
diff --git a/cmd/portrelay-agent/web/index.html b/cmd/portrelay-agent/web/index.html
index d2e55ed..22134c2 100644
--- a/cmd/portrelay-agent/web/index.html
+++ b/cmd/portrelay-agent/web/index.html
@@ -747,7 +747,8 @@
const fmtSecs = secs => {
secs = Math.max(0, Math.floor(secs || 0));
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), sec = secs % 60;
- return h > 0 ? `${h}小时${m}分` : (m > 0 ? `${m}分${sec}秒` : `${sec}秒`);
+ const pad = n => String(n).padStart(2, '0');
+ return h > 0 ? `${h}小时${pad(m)}分` : (m > 0 ? `${m}分${pad(sec)}秒` : `${sec}秒`);
};
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天`
: (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : (m > 60 ? `${Math.floor(m / 60)} 小时 ${m % 60} 分钟` : `${m} 分钟`));
diff --git a/docs/images/app-access.png b/docs/images/app-access.png
index 9a5a6fa..78c178b 100644
Binary files a/docs/images/app-access.png and b/docs/images/app-access.png differ
diff --git a/docs/images/app-clients.png b/docs/images/app-clients.png
index 5861b60..1cda18b 100644
Binary files a/docs/images/app-clients.png and b/docs/images/app-clients.png differ
diff --git a/docs/images/app-edit.png b/docs/images/app-edit.png
index 79268f7..ccbff2e 100644
Binary files a/docs/images/app-edit.png and b/docs/images/app-edit.png differ
diff --git a/docs/images/app-group.png b/docs/images/app-group.png
index cd38af6..a57bced 100644
Binary files a/docs/images/app-group.png and b/docs/images/app-group.png differ
diff --git a/docs/images/app-invite.png b/docs/images/app-invite.png
index a0c2f34..7974fae 100644
Binary files a/docs/images/app-invite.png and b/docs/images/app-invite.png differ
diff --git a/docs/images/app-main-dark.png b/docs/images/app-main-dark.png
index 62c36e8..f9138a6 100644
Binary files a/docs/images/app-main-dark.png and b/docs/images/app-main-dark.png differ
diff --git a/docs/images/app-main-light.png b/docs/images/app-main-light.png
index f07632a..d2c2e87 100644
Binary files a/docs/images/app-main-light.png and b/docs/images/app-main-light.png differ
diff --git a/docs/images/auth-form.png b/docs/images/auth-form.png
index 4a3e3f8..986791a 100644
Binary files a/docs/images/auth-form.png and b/docs/images/auth-form.png differ
diff --git a/docs/images/auth-ok.png b/docs/images/auth-ok.png
index 89ab2a5..7c25724 100644
Binary files a/docs/images/auth-ok.png and b/docs/images/auth-ok.png differ
diff --git a/docs/images/auth-pending.png b/docs/images/auth-pending.png
index a2eec5f..f1f67f9 100644
Binary files a/docs/images/auth-pending.png and b/docs/images/auth-pending.png differ
diff --git a/docs/images/auth-renew-ok.png b/docs/images/auth-renew-ok.png
new file mode 100644
index 0000000..ba28921
Binary files /dev/null and b/docs/images/auth-renew-ok.png differ
diff --git a/docs/images/auth-renew-pending.png b/docs/images/auth-renew-pending.png
new file mode 100644
index 0000000..3088ae2
Binary files /dev/null and b/docs/images/auth-renew-pending.png differ
diff --git a/docs/images/auth-renew.png b/docs/images/auth-renew.png
new file mode 100644
index 0000000..1d7267e
Binary files /dev/null and b/docs/images/auth-renew.png differ
diff --git a/docs/images/invite-flow.svg b/docs/images/invite-flow.svg
index 2cbbc5d..608fad9 100644
--- a/docs/images/invite-flow.svg
+++ b/docs/images/invite-flow.svg
@@ -1,4 +1,4 @@
-