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);