chore(sanitize): 备案号与域名一律可配置,仓库不再预置真实域名/备案号/内网地址

用户要求:备案号与域名都应当可配置,仓库内容要脱敏。

功能(不只是替换字面量):
- 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**;
  原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上
- 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖
- auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名,
  cookie 默认 host-only、首页默认相对路径 "/"
- gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页)
- 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL,
  避免"去掉写死默认值"导致跨子域单点登录失效

脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定);
测试夹具/示例/错误页/脚本/后台占位提示全部通用化。

README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。
测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
This commit is contained in:
HomeDash Bot
2026-09-12 10:10:52 +08:00
parent b01e49de49
commit b99319b38b
14 changed files with 158 additions and 31 deletions

View File

@ -390,6 +390,22 @@ NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v50
- 「从现有 nginx 配置导入」可把旧 portal-nginx 的配置整段贴进来,解析出全部域名→后端初稿
(含认证状态、免认证路径、SSE、备案注入、自签证书、auth/portal 类型);**解析只读,确认后才保存**
### 跨子域单点登录需要的两个环境变量
登录 cookie 不再内置任何域名,**默认下发 host-only cookie**(登录站点自身可用)。
要让 `login.example.com` 登录后能访问 `dashy.example.com` 等子域,必须设置:
| 变量 | 值 | 说明 |
| --- | --- | --- |
| `PORTAL_COOKIE_DOMAIN` | `.example.com` | cookie 作用域(前导点表示含所有子域) |
| `PORTAL_HOME_URL` | `https://example.com/` | 登录成功后跳回的门户地址(默认 `/`,相对跳转) |
首装脚本在给了 `NAVI_DOMAIN` 时会自动带上这两个变量;**升级已有部署时请确认容器 env 里已设置**
(早期版本靠代码里的写死默认值工作,现在已移除,不设会导致跨子域登录失效)。
同理,`gateway.yml` 的 `loginUrl` 也不再预置域名:受保护路由要求配置认证与登录地址,
留空会在生成配置时报错并说明原因(不会静默生成一个跳不出去的登录页)。
### 统一登录账号
管理后台「统一登录账号」写入数据卷 `<data>/portal-auth.json`(**只存加盐哈希,不存明文**)。
@ -493,7 +509,7 @@ host 网络启动容器 → 应用配置 + 自检。**自检不全绿就退出**
| `NAVI_SYNC_TOKEN` / `NAVI_ADMIN_TOKEN` | 空→自动生成 | 历史同步令牌 / 后台 API 令牌 |
| `NAVI_BUILD` / `NAVI_TAR` | `1` / 空 | `0` 表示用 `docker load -i $NAVI_TAR` 导入现成镜像 |
| `NAVI_SSH_PASS`(zd-host.sh) | 空 | ssh 密码;用密钥则留空 |
| `NAVI_HOST`(zd-host.sh) | `admin@10.0.0.10` | 开发机侧默认值,换机器需覆盖 |
| `NAVI_HOST`(zd-host.sh / zd.sh) | **必填、无默认** | 目标机 ssh 地址 `user@host`(仓库不预置任何真实地址);缺失会直接以退出码 2 报错 |
### 证书与初始网关配置

View File

@ -22,10 +22,13 @@ import (
const portalCookieName = "portal_session"
var (
portalUsername = envOr("PORTAL_USERNAME", "admin")
portalPassword = envOr("PORTAL_PASSWORD", "changeme")
portalCookieDomain = envOr("PORTAL_COOKIE_DOMAIN", ".example.com")
portalHomeURL = envOr("PORTAL_HOME_URL", "https://dashy.example.com")
portalUsername = envOr("PORTAL_USERNAME", "admin")
portalPassword = envOr("PORTAL_PASSWORD", "changeme")
// 均不预置真实域名:cookie 域为空时下发 host-only cookie(同一站点可用,
// 跨子域单点登录请设置 PORTAL_COOKIE_DOMAIN=.example.com);
// 门户首页为空时用相对跳转 "/"。
portalCookieDomain = envOr("PORTAL_COOKIE_DOMAIN", "")
portalHomeURL = envOr("PORTAL_HOME_URL", "/")
portalSessionTTL = 24 * time.Hour
portalSecretOnce sync.Once
portalSecret []byte

View File

@ -2,6 +2,7 @@ package main
import (
"fmt"
"html"
"os"
"path/filepath"
"sort"
@ -58,9 +59,10 @@ func defaultGatewayConfig() *GatewayConfig {
return &GatewayConfig{
ListenPort: 8081,
ListenTLSPort: 8443,
AuthURL: "http://127.0.0.1:3005/auth/verify",
LoginURL: "https://login.example.com/login",
Routes: []Route{},
AuthURL: "http://127.0.0.1:3005/auth/verify", // 本机内部地址,非外部域名
// 登录地址不预置任何域名:未配置时由后台/首装脚本填入(gateway.yml loginUrl)
LoginURL: "",
Routes: []Route{},
}
}
@ -240,9 +242,13 @@ func writeProxyLocation(b *strings.Builder, r Route, path string, authOff bool)
b.WriteString(" proxy_ssl_verify off;\n")
}
if r.InjectBeian {
b.WriteString(" sub_filter '</footer>' '</footer><div class=\"navi-proxy-beian\"><a href=\"https://beian.miit.gov.cn\" target=\"_blank\">京ICP备00000000号-1</a> | <a href=\"https://beian.mps.gov.cn\" target=\"_blank\">京公网安备00000000000000号</a></div>';\n")
b.WriteString(" sub_filter_once off;\n")
b.WriteString(" proxy_set_header Accept-Encoding \"\";\n")
// 备案号来自站点信息(可配置);未配置则不注入,避免把别人的备案号印到你的页面上。
if snip := nginxSnippetSafe(beianSnippet()); snip != "" {
fmt.Fprintf(b, " sub_filter '</footer>' '</footer><div class=\"navi-proxy-beian\">%s</div>';\n", snip)
b.WriteString(" sub_filter_once off;\n")
// sub_filter 要生效必须关掉上游压缩,故只在真正注入时才加这行
b.WriteString(" proxy_set_header Accept-Encoding \"\";\n")
}
}
b.WriteString(" proxy_redirect off;\n }\n")
}
@ -293,8 +299,37 @@ func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error {
return nil
}
// beianSnippet 生成注入被代理页面的备案 HTML:来自后台「站点信息」的 ICP/公网安备,
// 两者都没配置时返回空串(此时不注入任何东西)。
// 原先这里写死了具体备案号,等于每个部署者都会带上别人的备案信息。
func beianSnippet() string {
cfg, err := loadConfig()
if err != nil || cfg == nil {
return ""
}
icp := strings.TrimSpace(mapString(cfg.PageInfo, "icp"))
gongan := strings.TrimSpace(mapString(cfg.PageInfo, "gongan"))
var parts []string
if icp != "" {
parts = append(parts, `<a href="https://beian.miit.gov.cn" target="_blank">`+html.EscapeString(icp)+`</a>`)
}
if gongan != "" {
parts = append(parts, `<a href="https://beian.mps.gov.cn" target="_blank">`+html.EscapeString(gongan)+`</a>`)
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, " | ")
}
// nginxSnippetSafe 去掉会破坏 nginx 配置或引发变量展开的字符。
func nginxSnippetSafe(s string) string {
return strings.NewReplacer("'", "", `\`, "", "\n", "", "\r", "", "$", "").Replace(s)
}
// RenderGatewayConf 生成完整统一入口配置。未知域名使用内置自定义 404 页面,
// 反向代理故障使用自定义 500 页面;不会再返回一行纯文本。
func RenderGatewayConf(cfg *GatewayConfig) (string, error) {
if cfg == nil {
return "", fmt.Errorf("配置为空")

View File

@ -12,7 +12,7 @@ const sampleConf = `map $host $backend {
gitea.example.com http://127.0.0.1:3000;
login.example.com http://127.0.0.1:4001;
dashy.example.com http://127.0.0.1:3003;
modulerouter.example.com http://10.0.0.11:8081;
modulerouter.example.com http://10.0.0.5:8081;
}
server {
listen 3080 ssl;

View File

@ -2,6 +2,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
@ -438,3 +439,57 @@ func TestRedirectDefaultsNoPortOnNonStandardPort(t *testing.T) {
t.Fatalf("默认不应带端口:\n%s", out)
}
}
// 注进被代理页面的备案号必须来自站点信息(可配置),不能再写死。
func TestBeianInjectionUsesPageInfo(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confFile, []byte("pageInfo:\n icp: 京ICP备12345678号-1\n gongan: 京公网安备11000000000001号\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !strings.Contains(out, "京ICP备12345678号-1") || !strings.Contains(out, "京公网安备11000000000001号") {
t.Fatalf("未使用配置的备案号:\n%s", out)
}
}
// 未配置备案号时不注入(不得残留任何硬编码备案号)。
func TestBeianInjectionSkippedWhenUnset(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confFile, []byte("pageInfo: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if strings.Contains(out, "navi-proxy-beian") {
t.Fatalf("未配置备案号却注入了:\n%s", out)
}
}
// 备案号里的引号/美元符不能破坏 nginx 配置。
func TestBeianInjectionSanitized(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confFile, []byte("pageInfo:\n icp: \"a';b$c\"\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if strings.Contains(out, "';b") || strings.Contains(out, "b$c") {
t.Fatalf("危险字符未清理:\n%s", out)
}
}

View File

@ -289,7 +289,7 @@ html[data-theme="dark"] .toast{ box-shadow:0 10px 30px rgba(0,0,0,.5) }
<div class="grp-in">添加转发</div>
<div class="row">
<div class="fg"><label>域名</label><input id="gw-n-host" placeholder="git.example.com"></div>
<div class="fg"><label>后端</label><input id="gw-n-up" placeholder="http://10.0.0.10:3000"></div>
<div class="fg"><label>后端</label><input id="gw-n-up" placeholder="http://127.0.0.1:3000"></div>
<div class="fg" style="max-width:170px"><label>类型</label>
<select id="gw-n-kind">
<option value="proxy">反向代理</option>

View File

@ -146,6 +146,8 @@ docker run -d --name navi --network host --restart unless-stopped \
-v "${DATA_DIR}:/app/data" -v "${CERT_DIR}:/etc/nginx/certs:ro" \
-e NAVI_SYNC_TOKEN="$SYNC_TOKEN" -e NAVI_ADMIN_TOKEN="$ADMIN_TOKEN" \
-e SEARXNG_BASE_URL="$BASE_URL" \
$([ -n "$DOMAIN" ] && echo "-e PORTAL_COOKIE_DOMAIN=.$DOMAIN") \
$([ -n "$DOMAIN" ] && echo "-e PORTAL_HOME_URL=https://$DOMAIN/") \
"$TAG" >/dev/null
echo " 容器已启动,等待健康检查…"
for i in $(seq 1 60); do

View File

@ -1,3 +1,6 @@
// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。
const BASE = process.env.PORTAL_BASE || 'https://dashy.example.com';
const DOM = process.env.PORTAL_COOKIE_DOMAIN || '.example.com';
const { chromium }=require('playwright-core');
function rgb(s){const m=s.match(/[\d.]+/g);return m?m.slice(0,4).map(Number):[]}
function L(s){const [r=0,g=0,b=0]=rgb(s);return Math.round(.2126*r+.7152*g+.0722*b)}
@ -10,10 +13,10 @@ const C=process.env.PORTAL_COOKIE;
for(const [w,h] of sizes){
for(const [path,name] of views){
const ctx=await b.newContext({ignoreHTTPSErrors:true,viewport:{width:w,height:h}});
await ctx.addCookies([{name:'portal_session',value:C,domain:'.example.com',path:'/',httpOnly:true,secure:true,sameSite:'Lax'}]);
await ctx.addCookies([{name:'portal_session',value:C,domain:DOM,path:'/',httpOnly:true,secure:true,sameSite:'Lax'}]);
const p=await ctx.newPage(); const errs=[]; p.on('pageerror',e=>errs.push(String(e)));
await p.emulateMedia({colorScheme:'dark'}); await p.addInitScript(()=>localStorage.removeItem('navi-theme'));
await p.goto('https://dashy.example.com'+path,{waitUntil:'networkidle',timeout:90000}); await p.waitForTimeout(3200);
await p.goto(BASE+path,{waitUntil:'networkidle',timeout:90000}); await p.waitForTimeout(3200);
const r=await p.evaluate(()=>{
const g=s=>{const e=document.querySelector(s);if(!e)return null;const c=getComputedStyle(e);return {c:c.color,bg:c.backgroundColor}};
const de=document.documentElement;

View File

@ -1,13 +1,16 @@
// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。
const BASE = process.env.PORTAL_BASE || 'https://dashy.example.com';
const DOM = process.env.PORTAL_COOKIE_DOMAIN || '.example.com';
const { chromium }=require('playwright-core');
const C=process.env.PORTAL_COOKIE;
(async()=>{
const b=await chromium.connectOverCDP('http://127.0.0.1:9222');
for(const [w,h] of [[1280,720],[1280,600],[1024,600],[900,520],[768,600],[390,780]]){
const ctx=await b.newContext({ignoreHTTPSErrors:true,viewport:{width:w,height:h}});
await ctx.addCookies([{name:'portal_session',value:C,domain:'.example.com',path:'/',httpOnly:true,secure:true,sameSite:'Lax'}]);
await ctx.addCookies([{name:'portal_session',value:C,domain:DOM,path:'/',httpOnly:true,secure:true,sameSite:'Lax'}]);
const p=await ctx.newPage();
await p.emulateMedia({colorScheme:'dark'}); await p.addInitScript(()=>localStorage.removeItem('navi-theme'));
await p.goto('https://dashy.example.com/minimal',{waitUntil:'networkidle',timeout:90000}); await p.waitForTimeout(1800);
await p.goto(BASE+'/minimal',{waitUntil:'networkidle',timeout:90000}); await p.waitForTimeout(1800);
const r=await p.evaluate(()=>{
const de=document.documentElement;
const foot=document.querySelector('.navi-footer');

View File

@ -1,3 +1,6 @@
// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。
const BASE = process.env.PORTAL_BASE || 'https://dashy.example.com';
const DOM = process.env.PORTAL_COOKIE_DOMAIN || '.example.com';
/* HomeDash 浏览器验收脚本(部署后跑一遍,覆盖本轮所有改动点)
*
* 依赖:node + ws(NODE_PATH 指向装了 ws 的目录)、headless chromium
@ -65,7 +68,7 @@ const PEEK = `(() => {
const t = await req('PUT', '/json/new?about:blank');
const p = await conn(t.webSocketDebuggerUrl);
await p('Page.enable'); await p('Runtime.enable'); await p('Network.enable'); await p('Log.enable');
await p('Network.setCookie', { name: 'portal_session', value: process.env.CK, domain: '.example.com', path: '/', secure: true });
await p('Network.setCookie', { name: 'portal_session', value: process.env.CK, domain: DOM, path: '/', secure: true });
await p('Network.setCacheDisabled', { cacheDisabled: true });
const ev = async (e) => (await p('Runtime.evaluate', { returnByValue: true, expression: e })).result.value;
const clickXY = async (x, y) => {
@ -82,7 +85,7 @@ const PEEK = `(() => {
console.log('══ A) 渲染开销:带 backdrop-filter 的元素数');
await p('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
for (const path of ['/', '/minimal']) {
await p('Page.navigate', { url: 'https://dashy.example.com' + path });
await p('Page.navigate', { url: BASE + path });
await sleep(8000);
const o = await ev(BLUR);
console.log(` ${path}: 总数=${o.total} 明细=${JSON.stringify(o.byClass)}`);
@ -91,7 +94,7 @@ const PEEK = `(() => {
console.log('══ B) 后台窄屏(390px)');
await p('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
await p('Page.navigate', { url: 'https://dashy.example.com/admin/' });
await p('Page.navigate', { url: BASE+'/admin/' });
await sleep(7500);
const o = await ev(`(() => {
const de = document.documentElement;
@ -116,7 +119,7 @@ const PEEK = `(() => {
console.log('══ C) 工作空间:收起态第一次点击只滑出、不生效');
await p('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
await p('Page.navigate', { url: 'https://dashy.example.com/workspace' });
await p('Page.navigate', { url: BASE+'/workspace' });
await sleep(8500);
let s0 = await ev(PEEK);
console.log(` 收起态(视口 ${s0.innerW}x${s0.innerH}): dock 露${s0.innerW - s0.dock.left}px @y=${s0.dock.cy} op=${s0.dock.op} 搜索球露${s0.search.right}px @y=${s0.search.cy} 搜索态=${s0.searchState}`);
@ -127,7 +130,7 @@ const PEEK = `(() => {
if (!revealOnly) fail++;
console.log(` ${revealOnly ? '✓' : '✗'} 第一次点右边缘 → 未跳转(path=${s1.pathname}) 已滑出(露${s1.innerW - s1.dock.left}px op=${s1.dock.op}) peek=${s1.peekOpen}`);
// 搜索球:第一次点 sliver 只滑出,不展开输入
await p('Page.navigate', { url: 'https://dashy.example.com/workspace' });
await p('Page.navigate', { url: BASE+'/workspace' });
await sleep(8000);
s0 = await ev(PEEK);
await clickXY(Math.max(3, s0.search.right - 7), s0.search.cy);

View File

@ -1,3 +1,6 @@
// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。
const BASE = process.env.PORTAL_BASE || 'https://dashy.example.com';
const DOM = process.env.PORTAL_COOKIE_DOMAIN || '.example.com';
const { chromium }=require('playwright-core');
function rgb(s){const m=s.match(/[\d.]+/g);return m?m.slice(0,4).map(Number):[]}
function L(a){return Math.round(.2126*(a[0]||0)+.7152*(a[1]||0)+.0722*(a[2]||0))}
@ -5,10 +8,10 @@ const C=process.env.PORTAL_COOKIE;
(async()=>{
const b=await chromium.connectOverCDP('http://127.0.0.1:9222');
const ctx=await b.newContext({ignoreHTTPSErrors:true,viewport:{width:1400,height:900}});
await ctx.addCookies([{name:'portal_session',value:C,domain:'.example.com',path:'/',httpOnly:true,secure:true,sameSite:'Lax'}]);
await ctx.addCookies([{name:'portal_session',value:C,domain:DOM,path:'/',httpOnly:true,secure:true,sameSite:'Lax'}]);
const p=await ctx.newPage(); const errs=[]; p.on('pageerror',e=>errs.push(String(e)));
await p.emulateMedia({colorScheme:'dark'}); await p.addInitScript(()=>{try{localStorage.removeItem('navi-theme')}catch(e){}});
await p.goto('https://dashy.example.com/workspace',{waitUntil:'networkidle',timeout:90000}); await p.waitForTimeout(3000);
await p.goto(BASE+'/workspace',{waitUntil:'networkidle',timeout:90000}); await p.waitForTimeout(3000);
const n=await p.evaluate(()=>[...document.querySelectorAll('nav.side-bar a,nav.side-bar button,.side-bar-item')].length);
let modal=null;
for(let i=0;i<n;i++){

View File

@ -17,7 +17,9 @@ set -euo pipefail
VER="${1:?用法: deploy/zd-host.sh <version>}"
TAG="homedash:ui-${VER}"
STAMP="$(date +%Y%m%d%H%M%S)"
HOST="${NAVI_HOST:-admin@10.0.0.10}"
[ -n "$HOST" ] || { echo "!! 请指定目标机:NAVI_HOST=user@host bash $0 ..." >&2; exit 2; }
HOST="${NAVI_HOST:-}" # 必填:NAVI_HOST=user@host(仓库不预置任何真实地址)
SSH_PASS="${NAVI_SSH_PASS:-}"
DATA_DIR="${NAVI_DATA_DIR:-/vol1/docker/navi-data}"
CERT_DIR="${NAVI_CERT_DIR:-/vol1/docker/cert}"

View File

@ -10,7 +10,7 @@
# deploy/zd.sh <version> 例: deploy/zd.sh v29
#
# 环境变量(默认值即本项目生产环境,可覆盖):
# NAVI_HOST 目标机 ssh 地址,默认 admin@10.0.0.10
# NAVI_HOST 目标机 ssh 地址(必填)
# NAVI_SSH_PASS 目标机密码(无 ssh key 时必填)
# NAVI_UPSTREAM_HOST 门户 nginx 里 dashy 域名对应的主机名,默认 dashy.example.com
# NAVI_PORTAL_CONF 门户 nginx 配置文件绝对路径
@ -24,7 +24,9 @@ set -euo pipefail
VER="${1:?用法: deploy/zd.sh <version>}"
TAG="homedash:ui-${VER}"
STAMP="$(date +%Y%m%d%H%M%S)"
HOST="${NAVI_HOST:-admin@10.0.0.10}"
[ -n "$HOST" ] || { echo "!! 请指定目标机:NAVI_HOST=user@host bash $0 ..." >&2; exit 2; }
HOST="${NAVI_HOST:-}" # 必填:NAVI_HOST=user@host
SSH_PASS="${NAVI_SSH_PASS:-}"
UPSTREAM_HOST="${NAVI_UPSTREAM_HOST:-dashy.example.com}"
PORTAL_CONF="${NAVI_PORTAL_CONF:-/vol1/docker/portal/nginx/nginx.conf}"

View File

@ -232,10 +232,10 @@ func TestDibToPNGFromICOPrefersLargest(t *testing.T) {
func TestSanitizeHostKey(t *testing.T) {
cases := map[string]string{
"example.com": "example.com",
"10.0.0.10:3000": "10.0.0.10_3000",
"example.com:8443": "example.com_8443",
"[::1]:8080": "___1__8080",
"example.com": "example.com",
"10.0.0.9:3000": "10.0.0.9_3000",
"example.com:8443": "example.com_8443",
"[::1]:8080": "___1__8080",
}
for in, want := range cases {
if got := sanitizeHostKey(in); got != want {