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
+ {error}
+ {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")}
{t("nodes.emptyBody")}