Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 15 additions & 2 deletions internal/control/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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("目标端口无效")
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions internal/control/mutate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
36 changes: 36 additions & 0 deletions internal/control/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
6 changes: 3 additions & 3 deletions internal/control/transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
87 changes: 87 additions & 0 deletions internal/control/validate.go
Original file line number Diff line number Diff line change
@@ -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
}
41 changes: 41 additions & 0 deletions internal/control/validate_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
},
Expand Down
19 changes: 17 additions & 2 deletions src/components/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,27 @@ export function Field({ label, children, id }: { label: string; children: ReactN
);
}

export function TextField({ label, ...props }: { label: string } & ComponentProps<typeof Input>) {
export function TextField({
label,
error,
...props
}: { label: string; error?: string } & ComponentProps<typeof Input>) {
const generatedId = useId();
const id = props.id ?? generatedId;
const errorId = `${id}-error`;
return (
<Field label={label} id={id}>
<Input {...props} id={id} />
<Input
{...props}
id={id}
aria-invalid={Boolean(error) || props["aria-invalid"]}
aria-describedby={error ? errorId : props["aria-describedby"]}
/>
{error ? (
<p id={errorId} role="alert" className="text-xs text-rose">
{error}
</p>
) : null}
</Field>
);
}
Expand Down
8 changes: 6 additions & 2 deletions src/components/nodes/issued-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function IssuedDialog({ issued, onClose }: { issued: Issued | null; onClo
<DialogHeader>
<DialogTitle>{issued?.name ? issued.name : t("nodes.issuedTitle")}</DialogTitle>
<DialogDescription>
{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") : ""}
Expand Down Expand Up @@ -100,7 +100,11 @@ function IssuedBody({ issued, onClose }: { issued: Issued; onClose: () => void }
return (
<>
<div role="status" className="mb-3 rounded-lg border border-line bg-paper-2 p-4">
<p className="text-sm font-medium">{online ? t("nodes.onlineReady") : t("nodes.waitingOnline")}</p>
<p className="text-sm font-medium">{t("nodes.issuedInList")}</p>
<p className="mt-1 text-xs text-stone">{t("nodes.issuedBody")}</p>
<p className="mt-3 text-sm font-medium">
{online ? t("nodes.onlineReady") : t("nodes.waitingOnline")}
</p>
<p className="mt-1 text-xs text-stone">
{online ? t("nodes.onlineReadyHint") : t("nodes.waitingOnlineHint")}
</p>
Expand Down
Loading
Loading