update readme

Kim committed Mar 30, 2026 at 20:39 UTC e0f93552d275842329e84c255c47d1628223f637
8 files changed +164 -31
README.md
+4 -2
@@ -1,8 +1,10 @@
1 # PORTAL — Public Open Relay To Access Localhost
2
3 +[English](./README.md) | [简体中文](./README.zh-CN.md)
4 +
5 <p align="center"><img width="800" alt="Portal Demo" src="./portal.gif" /></p>
6
5 -<p align="center">Expose your local application to the public internet — no port forwarding, no NAT, no DNS setup.<br />Portal is a self-hosted relay network. You can connect to any relay or run your own.</p><br />
7 +<p align="center">Expose your local application to the public internet - no port forwarding, no NAT, no DNS setup.<br />Portal is a self-hosted relay network with end-to-end encryption (E2EE). You can connect to any relay or run your own.</p><br />
8
9 ## Why Portal?
10
@@ -64,7 +66,7 @@ portal expose 3000 --relays https://localhost:4017
66
67 ```powershell
68 $ProgressPreference = 'SilentlyContinue'
67 -irm https://localhost:4017/install.ps1 | iex
69 +irm https://localhost:4017/install.ps1 | iex
70 portal expose 3000 --relays https://localhost:4017
71 ```
72
README.zh-CN.md new
+115
@@ -0,0 +1,115 @@
1 +# PORTAL - Public Open Relay To Access Localhost
2 +
3 +[English](./README.md) | [简体中文](./README.zh-CN.md)
4 +
5 +<p align="center"><img width="800" alt="Portal Demo" src="./portal.gif" /></p>
6 +
7 +<p align="center">安全地将你的本地应用暴露到公共互联网,无需端口转发、NAT 配置或 DNS 设置。<br />Portal 是一个具备端到端加密(E2EE)的自托管中继网络。你可以连接任意中继,也可以自行部署。</p><br />
8 +
9 +## 为什么选择 Portal?
10 +
11 +将本地服务发布到互联网通常很复杂。
12 +一般需要开放入站端口、配置 NAT 或防火墙、管理 DNS,并终止 TLS。
13 +
14 +Portal 通过反转连接模型来消除这些复杂性。
15 +应用主动向中继建立出站连接,由中继将服务暴露到公共互联网,并将入站流量路由回应用,同时保持端到端 TLS。
16 +
17 +与其他隧道服务不同,Portal 是自托管且无许可门槛的。你可以在自己的域名上运行中继,也可以连接任意中继。
18 +
19 +## 特性
20 +
21 +- **NAT 友好连接**:无需开放入站端口,也可在 NAT 或防火墙后工作
22 +- **自动子域路由**:为每个应用分配独立子域名(`your-app.<base-domain>`)
23 +- **端到端租户 TLS**:中继基于 SNI 路由,而租户 TLS 在你这一侧终止,并通过中继支持的无密钥签名完成证书签发
24 +- **无许可托管**:任何人都可以运行自己的 Portal,无需审批
25 +- **一条命令完成设置**:用一条命令暴露任意本地应用
26 +- **UDP 中继(实验性)**:支持原始 UDP 中继
27 +
28 +## Portal 如何提供端到端加密
29 +
30 +Portal 的设计确保租户 TLS 在你这一侧终止,而不是在中继侧终止。在正常数据路径中,中继只转发加密流量,无法访问租户 TLS 明文。
31 +
32 +1. 中继接受公共连接,并且只读取基于 SNI 路由所需的 TLS ClientHello。
33 +2. 它通过反向会话转发租户连接的原始加密字节,而不会终止租户 TLS。
34 +3. 你这一侧的 Portal 客户端充当 TLS 服务器,并在本地完成租户握手。
35 +4. 对于由中继托管的域名,Portal 客户端通过 `/v1/sign` 获取证书签名,将中继仅作为无密钥签名预言机使用。
36 +5. 会话密钥完全在你这一侧导出。中继只提供证书签名,不会接收租户流量密钥材料。
37 +6. 握手完成后,中继继续转发密文,无需获取租户 TLS 明文即可保持流量路由。
38 +
39 +Portal 还会检查中继是否真正保留了 TLS 透传。Portal 客户端会连接自己的公共端点,并比较由客户端两端观测到的 TLS exporter 值。如果两者不一致,`portal expose` 默认会拒绝该中继。
40 +
41 +## 组件
42 +
43 +- **Relay**:将公共请求路由到正确已连接应用的服务器。
44 +- **Tunnel**:一个 CLI 代理,用于通过中继转发你的本地应用。
45 +
46 +## 快速开始
47 +
48 +### 运行 Portal Relay
49 +
50 +```bash
51 +git clone https://github.com/gosuda/portal
52 +cd portal
53 +docker compose up
54 +```
55 +
56 +如需部署到公网域名,参见 [docs/deployment.md](docs/deployment.md)。
57 +
58 +### 通过 Tunnel 暴露本地服务
59 +
60 +对于使用 `docker compose up` 启动的本地中继:
61 +
62 +```bash
63 +curl -ksSL https://localhost:4017/install.sh | bash
64 +portal expose 3000 --relays https://localhost:4017
65 +```
66 +
67 +```powershell
68 +$ProgressPreference = 'SilentlyContinue'
69 +irm https://localhost:4017/install.ps1 | iex
70 +portal expose 3000 --relays https://localhost:4017
71 +```
72 +
73 +使用托管中继时,请将 `https://localhost:4017` 替换为你的中继 URL。
74 +中继落地页也会为当前中继生成精确的安装命令。
75 +CLI 用法和安装细节请参见 [cmd/portal-tunnel/README.md](cmd/portal-tunnel/README.md)。
76 +
77 +### 使用 Go SDK(高级)
78 +
79 +更多示例请参见 [portal-toys](https://github.com/gosuda/portal-toys)。
80 +
81 +## 架构
82 +
83 +参见 [docs/architecture.md](docs/architecture.md)。
84 +架构决策记录参见 [docs/adr/README.md](docs/adr/README.md)。
85 +
86 +## 示例
87 +
88 +| 示例 | 说明 |
89 +|------|------|
90 +| [nginx reverse proxy](docs/examples/nginx-proxy/) | 在 nginx 后部署 Portal,并使用 L4 SNI 路由与 TLS 终止 |
91 +| [nginx + multi-service](docs/examples/nginx-proxy-multi-service/) | 在单个 nginx 实例后与其他 Web 服务一起运行 Portal |
92 +
93 +## 公共 Relay Registry
94 +
95 +Portal 官方公共中继注册表地址为:
96 +
97 +`https://raw.githubusercontent.com/gosuda/portal/main/registry.json`
98 +
99 +Portal tunnel 客户端默认可以包含该注册表,Relay UI 也会从同一路径读取官方中继列表。
100 +
101 +如果你运营一个公开的 Portal 中继,请提交 Pull Request,将你的中继 URL 添加到 `registry.json`。保持注册表更新能让社区更容易发现公共中继。
102 +
103 +## 贡献
104 +
105 +欢迎社区贡献!
106 +
107 +1. Fork 本仓库
108 +2. 创建功能分支(`git checkout -b feature/amazing-feature`)
109 +3. 提交你的变更(`git commit -m 'Add amazing feature'`)
110 +4. 推送到分支(`git push origin feature/amazing-feature`)
111 +5. 创建 Pull Request
112 +
113 +## 许可证
114 +
115 +MIT License - 参见 [LICENSE](LICENSE)
cmd/portal-tunnel/README.md
+5 -4
@@ -46,8 +46,8 @@ portal expose --name myapp \
46 - Route matching is longest-prefix-first. `/api=http://127.0.0.1:3001` matches `/api/*` and strips the `/api` prefix before proxying to the upstream.
47 - Routed HTTP mode automatically forwards `X-Forwarded-*`, rewrites upstream `Location` redirects back to the public route path, and strips loopback cookie domains while remapping cookie paths to the mounted route prefix.
48 - `--name` is optional. When omitted, the CLI generates a name for that run.
49 -- `--relays` sets the relay API URLs for that run.
50 -- `--discovery=false` disables the public registry seed list and the discovery expansion loop for that run.
49 +- `--relays` adds explicit relay API URLs for that run.
50 +- `--discovery=false` disables the public registry seed list and the runtime relay discovery expansion loop for that run. With `--discovery=false`, only the explicit `--relays` values are used.
51 - `--ban-mitm` enables strict rejection when the TLS self-probe detects termination in the path.
52
53 Flags:
@@ -61,7 +61,7 @@ Flags:
61 --tags Service tags metadata (comma-separated)
62 --thumbnail Service thumbnail URL metadata
63 --owner Service owner metadata
64 ---hide Hide service from discovery
64 +--hide Hide service from relay listing screens
65 --http-route HTTP route mapping in PATH=UPSTREAM form; repeat for multiple routes
66 ```
67
@@ -69,6 +69,7 @@ Flags:
69
70 - Prints the relay URLs that the CLI will use for the current invocation.
71 - `--relays` adds explicit relay URLs, and `--default-relays=false` disables the public registry list for the current listing run.
72 +- Unlike `portal expose`, `portal list` does not run the relay discovery expansion loop. It only resolves the registry seed list plus explicit `--relays` values.
73
74 Legacy execution compatibility has been removed:
75
@@ -82,7 +83,7 @@ Legacy execution compatibility has been removed:
83 - `install.ps1` installs `portal.exe` for the current Windows user and updates the user `PATH`.
84 - The installer does not write a config file.
85 - `portal expose 3000` still works after install because discovery is enabled by default.
85 -- Use `--relays https://portal.example.com` only when you want to target a specific relay explicitly.
86 +- To target only a specific relay, use `--relays https://portal.example.com --discovery=false`.
87
88 ## Notes
89
cmd/portal-tunnel/main.go
+1 -1
@@ -61,7 +61,7 @@ func runExposeCommand(args []string) error {
61 utils.StringFlag(fs, &flags.tags, "tags", "", "Service tags metadata (comma-separated)")
62 utils.StringFlag(fs, &flags.owner, "owner", "", "Service owner metadata")
63 utils.StringFlag(fs, &flags.thumbnail, "thumbnail", "", "Service thumbnail URL metadata")
64 - utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from discovery")
64 + utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from relay listing screens")
65 utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM form; repeat to aggregate multiple local HTTP services behind one public URL")
66 utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
67 utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR")
frontend/src/components/TunnelCommandForm.tsx
+22 -7
@@ -340,11 +340,12 @@ function FullTunnelCommandForm({
340 Pick<TunnelCommandFormProps, "className">) {
341 const inputId = useId();
342 const isTerminal = theme === "terminal";
343 + const currentOrigin = readCurrentOrigin();
344
345 const [relayUrls, setRelayUrls] = useState<string[]>(() => [
345 - readCurrentOrigin(),
346 + currentOrigin,
347 ]);
347 - const [defaultRelays, setDefaultRelays] = useState(true);
348 + const [discoveryEnabled, setDiscoveryEnabled] = useState(true);
349 const [urlInput, setUrlInput] = useState("");
350 const [enableUDP, setEnableUDP] = useState(false);
351 const [udpPort, setUDPPort] = useState("");
@@ -376,7 +377,7 @@ function FullTunnelCommandForm({
377 handleShuffleName,
378 } = useTunnelCommand({
379 relayUrls,
379 - defaultRelays,
380 + discovery: discoveryEnabled,
381 thumbnailURL: normalizedThumbnailURL,
382 enableUDP,
383 udpPort,
@@ -398,6 +399,9 @@ function FullTunnelCommandForm({
399 };
400
401 const removeRelayURL = (url: string) => {
402 + if (!discoveryEnabled && relayUrls.length <= 1) {
403 + return;
404 + }
405 setRelayUrls((prev) => prev.filter((candidate) => candidate !== url));
406 };
407
@@ -525,11 +529,17 @@ function FullTunnelCommandForm({
529 >
530 <input
531 type="checkbox"
528 - checked={defaultRelays}
529 - onChange={(event) => setDefaultRelays(event.target.checked)}
532 + checked={discoveryEnabled}
533 + onChange={(event) => {
534 + const nextEnabled = event.target.checked;
535 + setDiscoveryEnabled(nextEnabled);
536 + if (!nextEnabled && relayUrls.length === 0) {
537 + setRelayUrls([currentOrigin]);
538 + }
539 + }}
540 className="h-4 w-4"
541 />
532 - <span>Include default registry</span>
542 + <span>Enable relay discovery</span>
543 </label>
544 </div>
545
@@ -557,9 +567,14 @@ function FullTunnelCommandForm({
567 onClick={() => removeRelayURL(url)}
568 className={cn(
569 "ml-1 rounded-sm p-0.5",
560 - isTerminal ? "hover:bg-white/10" : "hover:bg-destructive/15"
570 + !discoveryEnabled && relayUrls.length <= 1
571 + ? "cursor-not-allowed opacity-40"
572 + : isTerminal
573 + ? "hover:bg-white/10"
574 + : "hover:bg-destructive/15"
575 )}
576 aria-label={`Remove ${url}`}
577 + disabled={!discoveryEnabled && relayUrls.length <= 1}
578 >
579 <X className="h-3 w-3" />
580 </button>
frontend/src/hooks/useTunnelCommand.ts
+5 -5
@@ -66,15 +66,15 @@ function splitDisplayCommand(command: string, os: TunnelCommandOS) {
66
67 interface TunnelCommandExtras {
68 relayUrls?: string[];
69 - defaultRelays?: boolean;
69 + discovery?: boolean;
70 thumbnailURL?: string;
71 enableUDP?: boolean;
72 udpPort?: string;
73 }
74
75 export function useTunnelCommand(extras: TunnelCommandExtras = {}) {
76 - const currentOrigin = useMemo(readCurrentOrigin, []);
77 - const nameSeed = useMemo(readTunnelNameSeed, []);
76 + const [currentOrigin] = useState(readCurrentOrigin);
77 + const [nameSeed] = useState(readTunnelNameSeed);
78
79 const [target, setTarget] = useState(DEFAULT_HOST);
80 const [name, setName] = useState("");
@@ -102,7 +102,7 @@ export function useTunnelCommand(extras: TunnelCommandExtras = {}) {
102 name: effectiveName,
103 nameSeed,
104 relayUrls: extras.relayUrls ?? [currentOrigin],
105 - defaultRelays: extras.defaultRelays ?? true,
105 + discovery: extras.discovery ?? true,
106 thumbnailURL: extras.thumbnailURL ?? "",
107 enableUDP: extras.enableUDP ?? false,
108 udpPort: extras.udpPort ?? "",
@@ -111,7 +111,7 @@ export function useTunnelCommand(extras: TunnelCommandExtras = {}) {
111 [
112 currentOrigin,
113 effectiveName,
114 - extras.defaultRelays,
114 + extras.discovery,
115 extras.enableUDP,
116 extras.relayUrls,
117 extras.thumbnailURL,
frontend/src/lib/tunnelCommand.test.ts
+4 -4
@@ -14,7 +14,7 @@ describe("tunnelCommand", () => {
14 name: "My App",
15 nameSeed: "web_portal",
16 relayUrls: ["https://localhost:4017"],
17 - defaultRelays: true,
17 + discovery: true,
18 thumbnailURL: "",
19 os: "unix" as const,
20 };
@@ -38,7 +38,7 @@ describe("tunnelCommand", () => {
38 name: "my-app",
39 nameSeed: "web_portal",
40 relayUrls: ["https://relay.example.com"],
41 - defaultRelays: false,
41 + discovery: false,
42 thumbnailURL: "https://example.com/thumb.png",
43 os: "windows" as const,
44 };
@@ -47,7 +47,7 @@ describe("tunnelCommand", () => {
47 [
48 `$ProgressPreference = 'SilentlyContinue'`,
49 `irm https://relay.example.com/install.ps1 | iex`,
50 - `portal expose localhost:3000 --name my-app --relays https://relay.example.com --default-relays=false --thumbnail https://example.com/thumb.png`,
50 + `portal expose localhost:3000 --name my-app --relays https://relay.example.com --discovery=false --thumbnail https://example.com/thumb.png`,
51 ].join("\n")
52 );
53 expect(buildTunnelDisplayCommand(options)).toBe(
@@ -55,7 +55,7 @@ describe("tunnelCommand", () => {
55 `$ProgressPreference = 'SilentlyContinue'`,
56 `irm https://relay.example.com/install.ps1 | iex`,
57 `portal expose localhost:3000 --name my-app`,
58 - `--relays https://relay.example.com --default-relays=false --thumbnail https://example.com/thumb.png`,
58 + `--relays https://relay.example.com --discovery=false --thumbnail https://example.com/thumb.png`,
59 ].join("\n")
60 );
61 });
frontend/src/lib/tunnelCommand.ts
+8 -8
@@ -13,7 +13,7 @@ export interface TunnelCommandOptions {
13 name: string;
14 nameSeed: string;
15 relayUrls: string[];
16 - defaultRelays: boolean;
16 + discovery: boolean;
17 thumbnailURL: string;
18 enableUDP?: boolean;
19 udpPort?: string;
@@ -33,7 +33,7 @@ export function normalizeTunnelCommandName(value: string): string {
33
34 export function buildTunnelCommand({
35 currentOrigin,
36 - defaultRelays,
36 + discovery,
37 enableUDP = false,
38 name,
39 nameSeed,
@@ -45,7 +45,7 @@ export function buildTunnelCommand({
45 }: TunnelCommandOptions): string {
46 const { installLine, exposeHead, exposeOptions } = buildTunnelCommandParts({
47 currentOrigin,
48 - defaultRelays,
48 + discovery,
49 enableUDP,
50 name,
51 nameSeed,
@@ -61,7 +61,7 @@ export function buildTunnelCommand({
61
62 export function buildTunnelDisplayCommand({
63 currentOrigin,
64 - defaultRelays,
64 + discovery,
65 enableUDP = false,
66 name,
67 nameSeed,
@@ -73,7 +73,7 @@ export function buildTunnelDisplayCommand({
73 }: TunnelCommandOptions): string {
74 const { installLine, exposeHead, exposeOptions } = buildTunnelCommandParts({
75 currentOrigin,
76 - defaultRelays,
76 + discovery,
77 enableUDP,
78 name,
79 nameSeed,
@@ -89,7 +89,7 @@ export function buildTunnelDisplayCommand({
89
90 function buildTunnelCommandParts({
91 currentOrigin,
92 - defaultRelays,
92 + discovery,
93 enableUDP = false,
94 name,
95 nameSeed,
@@ -122,8 +122,8 @@ function buildTunnelCommandParts({
122 if (relayUrls.length > 0) {
123 exposeArgs.push(`--relays ${formatToken(relayURLValue, os)}`);
124 }
125 - if (!defaultRelays) {
126 - exposeArgs.push("--default-relays=false");
125 + if (!discovery) {
126 + exposeArgs.push("--discovery=false");
127 }
128
129 const normalizedThumbnailURL = normalizeAbsoluteHTTPURL(thumbnailURL);