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
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ test:
go test ./...

test-race:
go test -race ./... -timeout=20m
# SQLite-backed server tests approach 20 minutes under the race detector on CI.
go test -race ./... -timeout=25m

test-tdd-red: test-tdd-red-client test-tdd-red-server

Expand Down
42 changes: 42 additions & 0 deletions docs/http-wildcard-domains.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# HTTP 隧道泛域名

HTTP 隧道的业务域名支持完整域名和开头连续的泛域名标签。配置不包含协议、端口或路径;固定后缀至少包含两个标签。忽略大小写和末尾的一个点。

| 配置 | 匹配示例 | 不匹配示例 |
| --- | --- | --- |
| `*.x.com` | `shop.x.com` | `x.com`、`a.b.x.com` |
| `*.a.b.com` | `shop.a.b.com` | `a.b.com` |
| `*.*.x.com` | `shop.dev.x.com` | `shop.x.com` |
| `*.*.*.x.com` | `shop.dev.eu.x.com` | `a.b.c.d.x.com` |

每个 `*` 仅匹配一个非空标签,最多允许三个连续的前导 `*`。固定后缀可以有更深层级;不支持 `app.*.x.com`、`foo*.x.com` 或四个 `*`。

## 匹配与转发

先匹配完整域名,再选择固定后缀最长的泛域名。例如 `shop.dev.eu.x.com` 的候选顺序为:

1. `shop.dev.eu.x.com`
2. `*.dev.eu.x.com`
3. `*.*.eu.x.com`
4. `*.*.*.x.com`

请求 Host 的端口不参与业务域名匹配。代理向目标服务保留实际请求的 Host 和 X-Forwarded-Host(包括原始大小写、端口),不会替换成配置中的泛域名,也不使用传入的 X-Forwarded-Host 选择隧道。路径、查询参数、WebSocket 和 SSE 沿用 HTTP 隧道的转发逻辑。

配置的管理地址优先于覆盖它的泛域名;内部控制和数据 WebSocket 通道仍按 NetsGo 子协议优先分流。

## 占用与冲突

- 完全相同的规范化域名规则全局唯一,同一用户也不能重复添加。
- 同一用户可以在不同客户端上添加有重叠的规则,用于精确域名或更具体的泛域名覆盖。
- 不同用户之间禁止有交集的规则。创建和修改在保存事务中检查,两个并发请求中只有一个可以取得冲突资源;失败不会修改已有配置或占用。
- 不同深度的规则不一定有交集。例如 `*.x.com` 与 `*.*.x.com`、`x.com` 都互不覆盖。
- 暂停、离线、等待启动、出错和服务重启均保留域名占用;只有成功删除或修改域名才释放相应占用。
- 已选中的更具体隧道不可用时返回 503,鉴权或来源限制拒绝时返回 401/403,代理失败时返回 502。不会继续尝试更宽的泛域名。删除更具体的规则后,同一用户的较宽规则才可能接管请求。

## DNS 与 HTTPS

需要自行将实际访问域名解析到 NetsGo 入口,并配置覆盖实际访问域名的 HTTPS 证书。本功能不创建 DNS 记录,也不扩展证书签发能力。多星号是 NetsGo 路由语法,不能直接作为多层通配证书使用;通常的 `*.x.com` 证书只覆盖一层子域名。

## 验证范围

新增测试覆盖域名语法和长度边界、具体域名集合与规则交集的一致性、优先级、不同用户隔离、事务回滚、并发占用、重启恢复、旧末尾点占用键、真实 yamux 代理、Host 保留、不可用与鉴权失败不回退、管理和内部通道优先级、WebSocket、SSE、API 创建和编辑,以及前端编辑回填。全仓库回归使用现有 CI 的 Go 测试、竞态测试、前端构建和测试及反向代理兼容测试。
6 changes: 3 additions & 3 deletions internal/server/http_dispatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ func TestDispatch_HTTPTunnelHTTPBasicAuth(t *testing.T) {
}
}

func TestDispatch_HTTPTunnel_PersistedInactiveStatusesDoNotRegisterHost(t *testing.T) {
func TestDispatch_HTTPTunnel_PersistedInactiveStatusesReserveHost(t *testing.T) {
testCases := []struct {
name string
status string
Expand All @@ -705,8 +705,8 @@ func TestDispatch_HTTPTunnel_PersistedInactiveStatusesDoNotRegisterHost(t *testi

s.StartHTTPOnly().ServeHTTP(w, req)

if w.Code != http.StatusNotFound {
t.Fatalf("inactive HTTP tunnel should not register host: want 404, got %d", w.Code)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("inactive HTTP tunnel should reserve its host: want 503, got %d", w.Code)
}
})
}
Expand Down
133 changes: 133 additions & 0 deletions internal/server/http_domain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package server

import (
"fmt"
"net"
"strconv"
"strings"
)

const maxHTTPDomainWildcards = 3

// HTTP patterns consist of up to three leading '*.' labels and a fixed FQDN.
// Each star consumes exactly one nonempty label; it never includes the apex.
// Management addresses continue to use the exact-only validateDomain function.
func validateHTTPDomain(domain string) error {
if domain != strings.TrimSpace(domain) {
return fmt.Errorf("domain cannot contain whitespace")
}
if len(strings.TrimSuffix(domain, ".")) > 253 {
return fmt.Errorf("domain length cannot exceed 253 characters")
}
suffix := domain
count := 0
for strings.HasPrefix(suffix, "*.") {
count++
suffix = strings.TrimPrefix(suffix, "*.")
}
if count > maxHTTPDomainWildcards {
return fmt.Errorf("domain supports at most %d leading wildcard labels", maxHTTPDomainWildcards)
}
if strings.Contains(suffix, "*") {
return fmt.Errorf("wildcards must be consecutive complete labels at the start of the domain")
}
return validateDomain(suffix)
}

func canonicalHTTPDomain(domain string) string {
return strings.TrimSuffix(strings.ToLower(domain), ".")
}

// Unlike a configured domain, the request authority can contain a port. Do not
// accept URL syntax or forwarded headers as an alternative routing authority.
func canonicalHTTPRouteHost(host string) string {
if host == "" || strings.ContainsAny(host, "* /\\?#@\t\r\n") {
return ""
}
if name, port, err := net.SplitHostPort(host); err == nil {
if strings.HasPrefix(host, "[") && net.ParseIP(name) == nil {
return ""
}
for _, digit := range port {
if digit < '0' || digit > '9' {
return ""
}
}
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return ""
}
host = name
} else if strings.ContainsAny(host, ":[]") {
// Preserve exact loopback routing used by existing installations/tests.
if strings.ContainsAny(host, "[]") && (!strings.HasPrefix(host, "[") || !strings.HasSuffix(host, "]")) {
return ""
}
if net.ParseIP(strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")) == nil {
return ""
}
}
if strings.HasSuffix(host, "..") {
return ""
}
host = canonicalHTTPDomain(strings.Trim(host, "[]"))
if net.ParseIP(host) != nil || host == "localhost" {
return host
}
if validateDomain(host) != nil {
return ""
}
return host
}

// Enumerating the only possible patterns gives a bounded, deterministic lookup
// order: exact, then increasingly general suffixes. No regex or map order.
func httpDomainCandidates(host string) []string {
host = canonicalHTTPRouteHost(host)
if host == "" {
return nil
}
result := []string{host}
if net.ParseIP(host) != nil {
return result
}
labels := strings.Split(host, ".")
for count := 1; count <= maxHTTPDomainWildcards && count <= len(labels)-2; count++ {
result = append(result, strings.Repeat("*.", count)+strings.Join(labels[count:], "."))
}
return result
}

func httpDomainMatches(pattern, host string) bool {
pattern = canonicalHTTPDomain(pattern)
for _, candidate := range httpDomainCandidates(host) {
if candidate == pattern {
return true
}
}
return false
}

// Patterns can overlap only at the same depth, with no incompatible literals.
// Inputs are validated configuration patterns, not arbitrary glob expressions.
func httpDomainsOverlap(left, right string) bool {
a := strings.Split(canonicalHTTPDomain(left), ".")
b := strings.Split(canonicalHTTPDomain(right), ".")
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != "*" && b[i] != "*" && a[i] != b[i] {
return false
}
}
return true
}

func httpDomainsConflict(left, leftOwner, right, rightOwner string) bool {
if !httpDomainsOverlap(left, right) {
return false
}
return canonicalHTTPDomain(left) == canonicalHTTPDomain(right) ||
leftOwner == "" || rightOwner == "" || leftOwner != rightOwner
}
134 changes: 134 additions & 0 deletions internal/server/http_domain_api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package server

import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"

"netsgo/pkg/protocol"
)

func httpDomainAPIRequest(name, clientID, domain string) tunnelCreateRequestAPI {
return tunnelCreateRequestAPI{
Name: name, Topology: tunnelTopologyServerExpose,
Ingress: endpointSpecAPI{
Location: tunnelEndpointLocationServer, Type: tunnelIngressTypeHTTPHost,
Config: mustRawJSON(httpHostConfigAPI{Domain: domain, AllowedSourceCIDRs: allowAllSourceCIDRs()}),
},
Target: endpointSpecAPI{
Location: tunnelEndpointLocationClient, ClientID: clientID, Type: tunnelTargetTypeTCPService,
Config: mustRawJSON(serviceConfigAPI{IP: "127.0.0.1", Port: 3000}),
},
TransportPolicy: tunnelTransportPolicyServerRelayOnly,
}
}

func TestHTTPDomainAPIValidationAndEdit(t *testing.T) {
s, handler, token, cleanup := setupTestServerWithStores(t, true)
defer cleanup()
target := createUnifiedAPITestClient(t, s, "install-wildcard", "wildcard")
for i, domain := range []string{"APP.X.COM.", "*.x.com", "*.*.x.com", "*.*.*.x.com", "*.a.b.com"} {
name := fmt.Sprintf("valid-%d", i)
req := httpDomainAPIRequest(name, target.ID, domain)
resp := doMuxRequest(t, handler, http.MethodPost, "/api/tunnels", token, mustRawJSON(req))
if resp.Code != http.StatusCreated {
t.Fatalf("create %q: %d %s", domain, resp.Code, resp.Body.String())
}
var created tunnelSpecAPI
if err := json.Unmarshal(resp.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
stored, err := s.store.GetTunnelByID(created.ID)
if err != nil || tunnelIngressDomain(stored) != strings.TrimSuffix(strings.ToLower(domain), ".") {
t.Fatalf("canonical domain not stored: %+v err=%v", stored, err)
}
// Updating the same rule excludes itself from global duplicate checks.
resp = doMuxRequest(t, handler, http.MethodPut, "/api/tunnels/"+created.ID, token,
mustRawJSON(tunnelUpdateRequestAPI{ExpectedRevision: created.Revision, Spec: req}))
if resp.Code != http.StatusOK {
t.Fatalf("self update %q: %d %s", domain, resp.Code, resp.Body.String())
}
before, err := s.store.GetTunnelByID(created.ID)
if err != nil {
t.Fatal(err)
}
req.Ingress.Config = mustRawJSON(httpHostConfigAPI{Domain: "*.*.*.*.x.com"})
resp = doMuxRequest(t, handler, http.MethodPut, "/api/tunnels/"+created.ID, token,
mustRawJSON(tunnelUpdateRequestAPI{ExpectedRevision: before.Revision, Spec: req}))
if resp.Code != http.StatusBadRequest || !responseHasTunnelErrorCode(t, resp, protocol.TunnelMutationErrorCodeDomainInvalid) {
t.Fatalf("invalid update %q: %d %s", domain, resp.Code, resp.Body.String())
}
after, err := s.store.GetTunnelByID(created.ID)
if err != nil {
t.Fatal(err)
}
assertStoredTunnelUnchangedAfterRejectedUpdate(t, before, after)
}
before, err := s.store.GetAllTunnels()
if err != nil {
t.Fatal(err)
}
for i, domain := range []string{"*.*.*.*.x.com", "app.*.x.com", "*.app.*.x.com", "foo*.x.com", "*.com", "*.x.com:80", "*.x.com/path", "*.x..com"} {
resp := doMuxRequest(t, handler, http.MethodPost, "/api/tunnels", token,
mustRawJSON(httpDomainAPIRequest(fmt.Sprintf("invalid-%d", i), target.ID, domain)))
var body tunnelMutationErrorResponse
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if resp.Code != http.StatusBadRequest || body.Field != "domain" || body.Code != protocol.TunnelMutationErrorCodeDomainInvalid {
t.Errorf("invalid %q: %d %+v", domain, resp.Code, body)
}
}
after, err := s.store.GetAllTunnels()
if err != nil || len(before) != len(after) {
t.Fatalf("invalid creates changed storage: before=%d after=%d err=%v", len(before), len(after), err)
}
}

func TestHTTPDomainAPICrossUserConflictAndRelease(t *testing.T) {
s, handler, tokenA, cleanup := setupTestServerWithStores(t, true)
defer cleanup()
targetA := createUnifiedAPITestClient(t, s, "install-owner-a", "owner-a")
ownerB, err := s.auth.adminStore.CreateUser("wildcard-owner-b", "Password123")
if err != nil {
t.Fatal(err)
}
targetB := createUnifiedAPITestClientForUser(t, s, ownerB.ID, "install-owner-b", "owner-b")
tokenB := loginAdminTokenLocal(t, handler, "wildcard-owner-b", "Password123")
resp := doMuxRequest(t, handler, http.MethodPost, "/api/tunnels", tokenA,
mustRawJSON(httpDomainAPIRequest("private-tunnel-name", targetA.ID, "*.*.x.com")))
if resp.Code != http.StatusCreated {
t.Fatalf("owner A create: %d %s", resp.Code, resp.Body.String())
}
var created tunnelSpecAPI
if err := json.Unmarshal(resp.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
for _, domain := range []string{"*.*.X.COM.", "*.dev.x.com", "app.dev.x.com"} {
resp = doMuxRequest(t, handler, http.MethodPost, "/api/tunnels", tokenB,
mustRawJSON(httpDomainAPIRequest("owner-b-tunnel", targetB.ID, domain)))
var body tunnelMutationErrorResponse
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if resp.Code != http.StatusConflict || body.Field != "domain" || body.Code != protocol.TunnelMutationErrorCodeIngressResourceConflict {
t.Fatalf("cross-user %q: %d %+v", domain, resp.Code, body)
}
for _, secret := range []string{"private-tunnel-name", targetA.ID, created.ID} {
if strings.Contains(resp.Body.String(), secret) {
t.Fatalf("conflict exposed other user's resource: %s", resp.Body.String())
}
}
}
resp = doMuxRequest(t, handler, http.MethodDelete, "/api/tunnels/"+created.ID, tokenA, nil)
if resp.Code != http.StatusNoContent {
t.Fatalf("delete: %d %s", resp.Code, resp.Body.String())
}
resp = doMuxRequest(t, handler, http.MethodPost, "/api/tunnels", tokenB,
mustRawJSON(httpDomainAPIRequest("owner-b-tunnel", targetB.ID, "*.dev.x.com")))
if resp.Code != http.StatusCreated {
t.Fatalf("claim after delete: %d %s", resp.Code, resp.Body.String())
}
}
Loading