Repository navigation
Expand file tree
/
Copy pathtraefik.ts
More file actions
141 lines (131 loc) · 5.54 KB
/
Copy pathtraefik.ts
File metadata and controls
141 lines (131 loc) · 5.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
import { z } from 'zod'
import { stringify as toYaml } from 'yaml'
/** 主机名必须是安全的 ASCII hostname——它会进 Traefik 的 Host() 规则。 */
const HostnameSchema = z
.string()
.min(1)
.max(253)
.regex(/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/)
export const TraefikRouteSchema = z.object({
instance: z.string().regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/),
hostname: HostnameSchema,
/**
* 该实例在**宿主回环**上发布的端口。入口转发到这里。
*
* 实例只把桥端口发布到 `127.0.0.1:<hostPort>`(不让局域网够到),所以入口不按容器名
* 解析——直接打回环端口;容器里的 Traefik 走 `host.docker.internal`(见 compose/local.yml)。
*/
hostPort: z.number().int().positive().max(65535),
})
export type TraefikRoute = z.infer<typeof TraefikRouteSchema>
export interface TraefikOptions {
gatewayAddress?: string
/** forward-auth 端点(控制面)。 */
forwardAuthAddress: string
/**
* 实例后端的上游主机名(见 `Env.INSTANCE_UPSTREAM_HOST`)。
* 省略 = `127.0.0.1`(Traefik 跑在宿主上);容器里的 Traefik 要传 `host.docker.internal`。
*/
upstreamHost?: string
authMiddlewareName?: string
entryPoint?: string
/**
* 挂在 router 上的 `tls` 块。省略 = 明文(本地 `web` 那档);
* `{}` = 用 file provider 里的静态证书(SNI 匹配;一张都没有时落到 Traefik 默认证书);
* 带 `certResolver` = ACME 自动签发。
* 挂到 `websecure` 的 router **必须显式给**,Traefik 不会因为 entryPoint 开了 TLS 就自动加。
*/
tls?: { certResolver?: string }
}
export interface TraefikConfig {
http: {
middlewares: Record<string, { forwardAuth: { address: string; authResponseHeaders: string[] } }>
/**
* 没有实例时**整个键省略**,不要输出空 map:Traefik v3.5 的 file provider 遇到
* 「有 middlewares、但 routers/services 是空 map」会拒收整份文件
* (`routers cannot be a standalone element`),platform-auth 中间件跟着丢。
* 省略和空 map 对 Traefik 是等价的。
*/
routers?: Record<
string,
{
rule: string
service: string
middlewares: string[]
entryPoints: string[]
tls?: { certResolver?: string }
}
>
services?: Record<string, { loadBalancer: { servers: Array<{ url: string }> } }>
}
}
/**
* 生成 Traefik 的动态配置(file provider)。
*
* 每个实例一个 router + service,全部挂同一个 forward-auth 中间件——
* **新增路由必须走这里**,否则会漏挂认证(见 docs/ARCHITECTURE.md §四)。
*
* 后端是**宿主回环上的端口**:实例只把端口发布到 `127.0.0.1`,入口直接转发到那里
* (容器里的 Traefik 走 `host.docker.internal`)。
*
* **回归红线**:发布到宿主回环**不是**普遍的隔离边界 —— 它在 **Docker Desktop** 上不成立
* (`host.docker.internal` 是代理到宿主 localhost 的别名,任何容器都能打到宿主回环上的发布端口,
* 于是同宿主的实例容器之间可以互访)。**Linux 宿主上成立**(容器够不到宿主回环,2026-09-12 实测:
* 宿主回环监听与别的容器发布的回环端口,经 `host.docker.internal` / 网桥网关全部 ECONNREFUSED)。
* 所以这条结论**依附于宿主平台**,换环境必须重验,别继承。
*
* 返回结构化对象(测试直接断言它,不经过序列化);落盘交给 `dynamic-config.ts` 的
* `writeDynamicConfig` —— 原子写与 YAML 序列化都在那儿,别在这里再做一遍。
*/
export function buildTraefikConfig(routes: TraefikRoute[], opts: TraefikOptions): TraefikConfig {
const authName = opts.authMiddlewareName ?? 'platform-auth'
// Traefik 跑在容器里时 `127.0.0.1` 是**容器自己的**回环,打实例会 502。
const upstreamHost = opts.upstreamHost ?? '127.0.0.1'
const entryPoint = opts.entryPoint ?? 'websecure'
const parsed = routes.map((r) => TraefikRouteSchema.parse(r))
const routers = Object.fromEntries(
parsed.map((r) => [
`instance-${r.instance}`,
{
rule: `Host(\`${r.hostname}\`)`,
service: `instance-${r.instance}`,
middlewares: opts.gatewayAddress === undefined ? [authName] : [],
entryPoints: [entryPoint],
...(opts.tls === undefined ? {} : { tls: opts.tls }),
},
]),
)
const services = Object.fromEntries(
parsed.map((r) => [
`instance-${r.instance}`,
{
loadBalancer: {
servers: [{ url: opts.gatewayAddress ?? `http://${upstreamHost}:${r.hostPort}` }],
},
},
]),
)
return {
http: {
middlewares: {
[authName]: {
forwardAuth: {
address: opts.forwardAuthAddress,
// 认证通过后由 forward-auth 注入,桥会校验 token
authResponseHeaders: ['X-Platform-Instance', 'X-Platform-Token', 'Cookie'],
},
},
},
// 空 map 会被 Traefik 拒收,没实例时就不输出这两个键(见 TraefikConfig.routers)
...(parsed.length === 0 ? {} : { routers, services }),
},
}
}
/**
* 序列化成 YAML——**必须是 YAML**:file provider 在 directory 模式下只读
* `.yml` / `.yaml` / `.toml`,`.json` 会被静默忽略(配置里看到的是空对象,
* 所有路由 404,且不报错)。
*/
export function renderTraefikConfig(routes: TraefikRoute[], opts: TraefikOptions): string {
return toYaml(buildTraefikConfig(routes, opts))
}