diff --git a/CHANGELOG.md b/CHANGELOG.md index e1b4f7b..312564b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- 控制台:服务列表展示访问方式标记,可用访问方式筛选,空状态点名凭证访问 / 临时放行 / 公开访问 +- 控制台:凭证访问的关闭公网业务端口改为中性文案,不再与“需处理”并列成故障 +- 控制台:登记节点在签发前需确认;取消不会写入节点;成功页说明节点已在列表中,并保留一次性凭证提示 +- 控制台与 API:节点名称、目标主机和 CIDR 在提交前校验,无效值无法保存 +- Console: service list shows access-mode badges and an access-mode filter; empty states name ticket access, temporary allow, and public access +- Console: a closed public business port in ticket access is described as intended, not as a fault +- Console: enrolling a node requires confirmation before issue; cancel does not create the node; the success surface states the node is already in the list and keeps the one-time credential warning +- Console and API: node names, target hosts, and CIDRs are validated before save + - 节点凭证隐藏改为可选:入口新增 `--hide-node-token` / `UMBRA_HIDE_NODE_TOKEN`,默认关闭。开启后保留文件或环境变量传递方式;默认安装命令与本地启动使用 `--token`。修改后需重新安装已有节点才能应用。 - Node credential hiding is now opt-in via `umbrad --hide-node-token` / `UMBRA_HIDE_NODE_TOKEN` (default: false). Enabled installations use files or environment variables; default installation commands and local launches use `--token`. Reinstall existing nodes to apply the change. diff --git a/internal/control/http.go b/internal/control/http.go index 9cb81d8..90c560a 100644 --- a/internal/control/http.go +++ b/internal/control/http.go @@ -776,8 +776,8 @@ func (c *Console) postNode(w http.ResponseWriter, r *http.Request) { if !jsonBody(w, r, &b) { return } - if strings.TrimSpace(b.Name) == "" { - writeErr(w, 400, "需要名称") + if err := checkNodeIdentity(b.Name, b.Comment); err != nil { + writeErr(w, 400, err.Error()) return } id, err := newID("nde") @@ -1014,6 +1014,9 @@ func validateMapping(proto, mode string, entry *int, localHost string, localPort if strings.TrimSpace(localHost) == "" { return fmt.Errorf("需要目标地址") } + if !validLocalHost(localHost) { + return fmt.Errorf("目标地址无效") + } if localPort < 1 || localPort > 65535 { return fmt.Errorf("目标端口无效") } @@ -1051,6 +1054,9 @@ func (c *Console) checkSpec(selfID string, spec wire.Mapping) error { if err := validateMapping(spec.Proto, spec.Mode, spec.EntryPort, spec.LocalHost, spec.LocalPort); err != nil { return err } + if err := validateCidrs(spec.AllowCidrs); err != nil { + return err + } if spec.Enabled { return c.portTaken(selfID, spec) } @@ -1063,6 +1069,9 @@ func (c *Console) checkSpecs(batch map[string]wire.Mapping) error { if err := validateMapping(spec.Proto, spec.Mode, spec.EntryPort, spec.LocalHost, spec.LocalPort); err != nil { return err } + if err := validateCidrs(spec.AllowCidrs); err != nil { + return err + } if !spec.Enabled || spec.Mode == "visitor" || spec.EntryPort == nil { continue } @@ -1112,6 +1121,10 @@ func (c *Console) postMapping(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err.Error()) return } + if err := validateCidrs(b.AllowCidrs); err != nil { + writeErr(w, 400, err.Error()) + return + } id, err := newID("map") if err != nil { writeRandFail(w) diff --git a/internal/control/mutate.go b/internal/control/mutate.go index 397f3cc..027aea4 100644 --- a/internal/control/mutate.go +++ b/internal/control/mutate.go @@ -35,9 +35,9 @@ func (c *Console) patchNode(w http.ResponseWriter, r *http.Request) { rehash := false if b.Name != nil { name := strings.TrimSpace(*b.Name) - if name == "" { + if err := validNodeName(name); err != nil { c.mu.Unlock() - writeErr(w, 400, "需要名称") + writeErr(w, 400, err.Error()) return } a.Name = name @@ -280,6 +280,11 @@ func (c *Console) patchMapping(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err.Error()) return } + if err := validateCidrs(next.AllowCidrs); err != nil { + c.mu.Unlock() + writeErr(w, 400, err.Error()) + return + } if next.Enabled { if err := c.portTaken(id, next); err != nil { c.mu.Unlock() diff --git a/internal/control/service_test.go b/internal/control/service_test.go index 373bf12..9136d7c 100644 --- a/internal/control/service_test.go +++ b/internal/control/service_test.go @@ -138,6 +138,42 @@ func TestDisabledProbeDoesNotDial(t *testing.T) { } } +func TestCreateRejectsInvalidHostNameAndCidr(t *testing.T) { + c, srv, _ := newTestConsole(t) + c.nodes["node"] = &nodeRec{ID: "node", Name: "QA", Enabled: true} + payload := map[string]any{ + "nodeId": "node", "name": "private", "proto": "tcp", "mode": "visitor", + "localHost": "not_a_host!!!", "localPort": 22, + } + res := doJSON(t, srv, "POST", "/v1/mappings", payload, nil) + if res.StatusCode != 400 { + t.Fatalf("bad host %d %s", res.StatusCode, readBody(t, res)) + } + payload["localHost"] = "127.0.0.1" + payload["allowCidrs"] = "not-a-cidr" + res = doJSON(t, srv, "POST", "/v1/mappings", payload, nil) + if res.StatusCode != 400 { + t.Fatalf("bad cidr %d %s", res.StatusCode, readBody(t, res)) + } + payload["allowCidrs"] = "10.0.0.0/8" + res = doJSON(t, srv, "POST", "/v1/mappings", payload, nil) + if res.StatusCode != 200 { + t.Fatalf("valid mapping %d %s", res.StatusCode, readBody(t, res)) + } +} + +func TestCreateNodeRejectsInvalidName(t *testing.T) { + _, srv, _ := newTestConsole(t) + res := doJSON(t, srv, "POST", "/v1/nodes", map[string]string{"name": "!!! bad/name?"}, nil) + if res.StatusCode != 400 { + t.Fatalf("bad name %d %s", res.StatusCode, readBody(t, res)) + } + res = doJSON(t, srv, "POST", "/v1/nodes", map[string]string{"name": "home-nas"}, nil) + if res.StatusCode != 200 { + t.Fatalf("valid name %d %s", res.StatusCode, readBody(t, res)) + } +} + func TestCreateServiceReturnsFullViewAndRejectsRevokedNode(t *testing.T) { c, srv, _ := newTestConsole(t) c.nodes["node"] = &nodeRec{ID: "node", Name: "QA", Enabled: true} diff --git a/internal/control/transfer.go b/internal/control/transfer.go index f0267a5..3d34cac 100644 --- a/internal/control/transfer.go +++ b/internal/control/transfer.go @@ -441,7 +441,7 @@ func validateBundle(b *ConfigBundle) error { return fmt.Errorf("节点标识重复") } nodes[n.ID] = struct{}{} - if err := checkNameComment(n.Name, n.Comment); err != nil { + if err := checkNodeIdentity(n.Name, n.Comment); err != nil { return err } } @@ -789,7 +789,7 @@ func (c *Console) buildPlanLocked(bundle ConfigBundle, bindings []importBinding) } switch pn.action { case "create": - if err := checkNameComment(pn.name, pn.comment); err != nil { + if err := checkNodeIdentity(pn.name, pn.comment); err != nil { pn.view.Error = true pn.view.Reason = err.Error() plan.canApply = false @@ -1129,7 +1129,7 @@ func (c *Console) restoreConfigLocked(s configSnap) { func (c *Console) mintNodeLocked(name, comment, os, arch string, neverExpire bool) (*nodeRec, string, error) { name = strings.TrimSpace(name) - if err := checkNameComment(name, comment); err != nil { + if err := checkNodeIdentity(name, comment); err != nil { return nil, "", err } os, arch = nodePlatform(os, arch) diff --git a/internal/control/validate.go b/internal/control/validate.go new file mode 100644 index 0000000..a51b545 --- /dev/null +++ b/internal/control/validate.go @@ -0,0 +1,87 @@ +package control + +import ( + "fmt" + "net" + "strings" + "unicode" +) + +func validNodeName(name string) error { + name = strings.TrimSpace(name) + if err := checkNameComment(name, ""); err != nil { + return err + } + if !nodeNameCharsetOK(name) { + return fmt.Errorf("名称无效") + } + return nil +} + +func checkNodeIdentity(name, comment string) error { + if err := validNodeName(name); err != nil { + return err + } + return checkNameComment(name, comment) +} + +func nodeNameCharsetOK(name string) bool { + hasAlnum := false + for _, r := range name { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + hasAlnum = true + case r == ' ' || r == '.' || r == '_' || r == '-': + default: + return false + } + } + return hasAlnum +} + +func validLocalHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" || len(host) > 253 { + return false + } + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + return net.ParseIP(host[1:len(host)-1]) != nil + } + if net.ParseIP(host) != nil { + return true + } + return validHostname(host) +} + +func validHostname(host string) bool { + host = strings.TrimSuffix(host, ".") + if host == "" || len(host) > 253 { + return false + } + for _, label := range strings.Split(host, ".") { + if !validHostLabel(label) { + return false + } + } + return true +} + +func validHostLabel(label string) bool { + n := len(label) + if n < 1 || n > 63 { + return false + } + for i := 0; i < n; i++ { + c := label[i] + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '_': + case c == '-': + if i == 0 || i == n-1 { + return false + } + default: + return false + } + } + return true +} diff --git a/internal/control/validate_test.go b/internal/control/validate_test.go new file mode 100644 index 0000000..7472423 --- /dev/null +++ b/internal/control/validate_test.go @@ -0,0 +1,41 @@ +package control + +import "testing" + +func TestValidNodeName(t *testing.T) { + for _, name := range []string{"home-nas", "家里 NAS", "n1", "studio.local"} { + if err := validNodeName(name); err != nil { + t.Errorf("%q: %v", name, err) + } + } + for _, name := range []string{"", " ", "!!! bad/name?", "---", "a/b", "no?"} { + if err := validNodeName(name); err == nil { + t.Errorf("%q: accepted", name) + } + } +} + +func TestValidLocalHost(t *testing.T) { + for _, host := range []string{"127.0.0.1", "localhost", "nas.local", "::1", "[::1]", "my_nas"} { + if !validLocalHost(host) { + t.Errorf("%q: rejected", host) + } + } + for _, host := range []string{"", "not_a_host!!!", "host name", "a/b"} { + if validLocalHost(host) { + t.Errorf("%q: accepted", host) + } + } +} + +func TestValidCidrsExamples(t *testing.T) { + if err := validateCidrs(""); err != nil { + t.Fatal(err) + } + if err := validateCidrs("10.0.0.0/8, 192.168.1.1"); err != nil { + t.Fatal(err) + } + if err := validateCidrs("not-a-cidr"); err == nil { + t.Fatal("not-a-cidr accepted") + } +} diff --git a/package.json b/package.json index dfb0a9e..8ddcd05 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "preview:stop": "node scripts/preview.mjs stop", "typecheck": "tsc --noEmit", "check:auth": "node scripts/check-auth-invariant.mjs", - "test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/auth/gate-identity.test.ts src/lib/umbra/format.test.ts src/lib/umbra/live.test.ts src/lib/umbra/page.test.ts src/lib/umbra/units.test.ts src/lib/umbra/service.test.ts src/lib/umbra/auth-error.test.ts src/lib/umbra/node-services.test.ts src/lib/umbra/traffic-scope.test.ts src/lib/umbra/quick-launch.test.ts src/lib/umbra/transfer.test.ts src/lib/i18n/i18n.test.ts", + "test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/auth/gate-identity.test.ts src/lib/umbra/format.test.ts src/lib/umbra/live.test.ts src/lib/umbra/page.test.ts src/lib/umbra/units.test.ts src/lib/umbra/service.test.ts src/lib/umbra/validate.test.ts src/lib/umbra/auth-error.test.ts src/lib/umbra/node-services.test.ts src/lib/umbra/traffic-scope.test.ts src/lib/umbra/quick-launch.test.ts src/lib/umbra/transfer.test.ts src/lib/i18n/i18n.test.ts", "lint": "eslint .", "format": "prettier --write ." }, diff --git a/src/components/field.tsx b/src/components/field.tsx index 8c15020..78dfb60 100644 --- a/src/components/field.tsx +++ b/src/components/field.tsx @@ -15,12 +15,27 @@ export function Field({ label, children, id }: { label: string; children: ReactN ); } -export function TextField({ label, ...props }: { label: string } & ComponentProps) { +export function TextField({ + label, + error, + ...props +}: { label: string; error?: string } & ComponentProps) { const generatedId = useId(); const id = props.id ?? generatedId; + const errorId = `${id}-error`; return ( - + + {error ? ( + + ) : null} ); } diff --git a/src/components/nodes/issued-dialog.tsx b/src/components/nodes/issued-dialog.tsx index e3dc12f..4fa8652 100644 --- a/src/components/nodes/issued-dialog.tsx +++ b/src/components/nodes/issued-dialog.tsx @@ -45,7 +45,7 @@ export function IssuedDialog({ issued, onClose }: { issued: Issued | null; onClo {issued?.name ? issued.name : t("nodes.issuedTitle")} - {t("nodes.issuedBody")} + {t("nodes.issuedInList")} {t("nodes.issuedBody")} {issued?.listen?.startsWith("127.0.0.1") ? t("nodes.issuedLoopback") : ""} {issued?.note ? ` ${issued.note}` : ""} {issued?.neverExpire ? t("nodes.issuedNever") : ""} @@ -100,7 +100,11 @@ function IssuedBody({ issued, onClose }: { issued: Issued; onClose: () => void } return ( <>
-

{online ? t("nodes.onlineReady") : t("nodes.waitingOnline")}

+

{t("nodes.issuedInList")}

+

{t("nodes.issuedBody")}

+

+ {online ? t("nodes.onlineReady") : t("nodes.waitingOnline")} +

{online ? t("nodes.onlineReadyHint") : t("nodes.waitingOnlineHint")}

diff --git a/src/components/pages/mappings-page.tsx b/src/components/pages/mappings-page.tsx index 4f9bbdb..1749021 100644 --- a/src/components/pages/mappings-page.tsx +++ b/src/components/pages/mappings-page.tsx @@ -57,6 +57,7 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) { const [q, setQ] = useState(""); const [view, setView] = useState("all"); const [proto, setProto] = useState("all"); + const [mode, setMode] = useState("all"); const [page, setPage] = useState(1); const [editor, setEditor] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); @@ -71,6 +72,7 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) { (!search.node || m.nodeId === search.node) && serviceMatches(m, q) && (proto === "all" || m.proto === proto) && + (mode === "all" || m.mode === mode) && (view === "all" || serviceState(m).kind === view), ); rows.sort( @@ -82,7 +84,7 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) { const pageData = pageOf(rows, page, PAGE_SIZE); useEffect(() => { setPage(1); - }, [q, view, proto, search.node]); + }, [q, view, proto, mode, search.node]); useEffect(() => { if (search.create && hasNode && !nodes.isPending) { const target = nodes.data?.find((node) => node.id === nodeId); @@ -133,7 +135,7 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) { ); const scopedNode = nodes.data?.find((node) => node.id === search.node); const affected = all.filter((m) => m.nodeId === search.node && m.enabled).length; - const filterCount = Number(view !== "all") + Number(proto !== "all"); + const filterCount = Number(view !== "all") + Number(proto !== "all") + Number(mode !== "all"); const statusOptions = [ { value: "all", label: t("services.allStates") }, { value: "attention", label: t("services.attention") }, @@ -144,6 +146,7 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) { setQ(""); setView("all"); setProto("all"); + setMode("all"); void navigate({ search: { node: nodeId, service: search.service } }); }; const loading = nodes.isPending || mappings.isPending; @@ -280,7 +283,9 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) { onClick={() => setFiltersOpen(!filtersOpen)} > - {filterCount ? t("services.filterCount", { n: filterCount }) : t("services.filter")} + {filterCount + ? t("services.filterCount", { n: filterCount }) + : t("services.filter")}
{filtersOpen ? ( @@ -321,6 +326,20 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) { ]} /> +
+ {t("services.mode")} +