From 632bbec42c0594f346985c6eae97e95c4d93c92e Mon Sep 17 00:00:00 2001
From: Chenow
Date: Sun, 20 Sep 2026 16:45:43 +0800
Subject: [PATCH] fix: surface access modes and tighten console
enroll/validation
Make visitor/spa/public first-class in the service list (badge, filter,
empty-state copy), stop treating a closed public business port as a fault,
require confirmation before issuing node credentials, and validate node
names, target hosts, and CIDRs in the console and API.
---
CHANGELOG.md | 9 ++
internal/control/http.go | 17 ++-
internal/control/mutate.go | 9 +-
internal/control/service_test.go | 36 +++++
internal/control/transfer.go | 6 +-
internal/control/validate.go | 87 ++++++++++++
internal/control/validate_test.go | 41 ++++++
package.json | 2 +-
src/components/field.tsx | 19 ++-
src/components/nodes/issued-dialog.tsx | 8 +-
src/components/pages/mappings-page.tsx | 40 +++++-
src/components/pages/nodes-page.tsx | 156 +++++++++++++--------
src/components/services/service-editor.tsx | 38 ++++-
src/components/services/service-list.tsx | 12 +-
src/components/ui/input.tsx | 1 +
src/lib/i18n/messages.ts | 44 ++++--
src/lib/umbra/service.test.ts | 8 ++
src/lib/umbra/service.ts | 52 +++++--
src/lib/umbra/validate.test.ts | 36 +++++
src/lib/umbra/validate.ts | 82 +++++++++++
src/styles.css | 16 ++-
21 files changed, 620 insertions(+), 99 deletions(-)
create mode 100644 internal/control/validate.go
create mode 100644 internal/control/validate_test.go
create mode 100644 src/lib/umbra/validate.test.ts
create mode 100644 src/lib/umbra/validate.ts
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 ? (
+
+ {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")}
+
+
) : null}
@@ -352,6 +371,17 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) {
) : null}
+ {mode !== "all" ? (
+
+ ) : null}
{q || filterCount ? (
- {scopedNode.status === "revoked" ? t("services.pickValid") : t("services.checkNode")}
+ {scopedNode.status === "revoked"
+ ? t("services.pickValid")
+ : t("services.checkNode")}
(page);
const list = pageData.items;
const empty =
- !nodes.isPending &&
- !nodes.isError &&
- pageData.total === 0 &&
- !q &&
- status === "all";
+ !nodes.isPending && !nodes.isError && pageData.total === 0 && !q && status === "all";
useEffect(() => {
setPage(1);
}, [q, status]);
@@ -243,6 +240,7 @@ export function NodesPage() {
onIssued={(v) => {
setEditor(null);
setIssued(v);
+ void qc.invalidateQueries({ queryKey: ["umbra"] });
}}
/>
) : null}
@@ -373,7 +371,9 @@ function EmptyNodes({ onCreate, onImport }: { onCreate: () => void; onImport: ()
return (
-
{t("nodes.emptyTitle")}
+
+ {t("nodes.emptyTitle")}
+
{t("nodes.emptyBody")}
@@ -511,8 +511,16 @@ function CreateNodeForm({ onIssued }: { onIssued: (v: Issued) => void }) {
const [os, setOs] = useState
("linux");
const [arch, setArch] = useState("amd64");
const [neverExpire, setNeverExpire] = useState(false);
+ const [confirming, setConfirming] = useState(false);
+ const [attempted, setAttempted] = useState(false);
+ const nameError = !name.trim()
+ ? t("nodes.needName")
+ : validNodeName(name)
+ ? null
+ : t("validate.nodeName");
+ const showNameError = attempted || Boolean(name.trim()) ? nameError : null;
const create = useMutation({
- mutationFn: () => createNode({ data: { name, comment, os, arch, neverExpire } }),
+ mutationFn: () => createNode({ data: { name: name.trim(), comment, os, arch, neverExpire } }),
onSuccess: (res) => {
toast.success(t("nodes.issuedToast"), { id: "create-node" });
onIssued({
@@ -520,6 +528,7 @@ function CreateNodeForm({ onIssued }: { onIssued: (v: Issued) => void }) {
token: res.token,
os,
arch,
+ name: name.trim(),
installCmd: res.installCmd,
dockerCmd: res.dockerCmd,
listen: res.listen,
@@ -535,16 +544,19 @@ function CreateNodeForm({ onIssued }: { onIssued: (v: Issued) => void }) {
return (
<>
- {t("nodes.enroll")}
- {t("nodes.enrollHint")}
+ {confirming ? t("nodes.confirmTitle") : t("nodes.enroll")}
+
+ {confirming ? t("nodes.confirmHint") : t("nodes.enrollHint")}
+