diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0a7011aa2..1b38cfce5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,7 @@ "username": "vscode" }, "ghcr.io/devcontainers/features/go:1": { - "version": "1.26" + "version": "1.27.3" }, "ghcr.io/devcontainers/features/node:1": { "version": "24" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index fa606c5b5..10d150b19 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -52,12 +52,11 @@ if ! command -v oras >/dev/null 2>&1; then fi # ---------- golangci-lint ---------- -# v2 release binaries work with the in-container Go version. The -# .golangci.yml has been migrated from v1 to v2 schema (v2 introduces new -# linters and removes deprecated ones). -GOLANGCI_VERSION="v2.12.2" +# v2.13 is the first release line supporting the workspace's Go 1.27. +# Keep this exact patch version aligned with the CI workflow. +GOLANGCI_VERSION="v2.13.2" GOLANGCI_BIN="/go/bin/golangci-lint" -if ! "$GOLANGCI_BIN" --version 2>/dev/null | grep -qE 'version v?2\.'; then +if ! "$GOLANGCI_BIN" --version 2>/dev/null | grep -qF "version ${GOLANGCI_VERSION#v} "; then log "installing golangci-lint ${GOLANGCI_VERSION}" GOBIN=/go/bin go install \ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_VERSION}" diff --git a/.github/actions/go-cache/action.yml b/.github/actions/go-cache/action.yml index 9ea044b46..65116b96f 100644 --- a/.github/actions/go-cache/action.yml +++ b/.github/actions/go-cache/action.yml @@ -11,7 +11,7 @@ inputs: go-version: description: Go version to setup required: false - default: '1.26' + default: '1.27' runs: using: composite @@ -20,7 +20,7 @@ runs: id: go with: go-version: ${{ inputs.go-version }} - # Resolve '1.26' to the newest 1.26.x patch instead of the runner's + # Resolve '1.27' to the newest 1.27.x patch (at least 1.27.3) instead of the runner's # preinstalled one, so stdlib fixes (spec 013 G1, govulncheck) land # without a manual pin bump. check-latest: true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 26d9ee778..f8874d3d1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -439,7 +439,7 @@ jobs: needs: [changes] if: (needs.changes.outputs.images == 'true') || contains(fromJSON(needs.changes.outputs.ci-jobs || '[]'), 'build-images') runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 permissions: contents: read steps: @@ -801,21 +801,21 @@ jobs: if: matrix.module == 'operator' || matrix.module == 'api' uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.12.2 + version: v2.13.2 working-directory: ${{ matrix.module }} args: --build-tags=envtest - name: lint (test/e2e - e2e build tags) if: matrix.module == 'test/e2e' uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.12.2 + version: v2.13.2 working-directory: ${{ matrix.module }} args: --build-tags=e2e - name: lint (other modules - no build tags) if: matrix.module != 'operator' && matrix.module != 'api' && matrix.module != 'test/e2e' uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.12.2 + version: v2.13.2 working-directory: ${{ matrix.module }} # Merge gate for spec 022: the project default telemetry destination diff --git a/agent/internal/rcon/nuclearoption_test.go b/agent/internal/rcon/nuclearoption_test.go index 248c6a7fb..9061d2c94 100644 --- a/agent/internal/rcon/nuclearoption_test.go +++ b/agent/internal/rcon/nuclearoption_test.go @@ -1275,18 +1275,47 @@ type pipeFrameCase struct { forbidErr string // substring the error must NOT contain (e.g. a detail suffix) } +type responseDeadlineConn struct { + net.Conn + deadlineSet chan<- struct{} +} + +func (c *responseDeadlineConn) SetDeadline(deadline time.Time) error { + if err := c.Conn.SetDeadline(deadline); err != nil { + return err + } + select { + case c.deadlineSet <- struct{}{}: + default: + } + return nil +} + // runPipeFrameCases sends each case's raw frame back to the client over net.Pipe // and checks the body or the error. func runPipeFrameCases(t *testing.T, tests []pipeFrameCase) { t.Helper() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + deadlineSet := make(chan struct{}, 1) client := newPipeClient(t, func(remote net.Conn) { if _, _, err := readNuclearOptionRequest(remote); err != nil { return } + if len(tt.frame) == 0 { + // Exec sets its response deadline after sending the request. + // Closing first can fail SetDeadline instead of the status read. + select { + case <-deadlineSet: + case <-t.Context().Done(): + return + } + } writeFrameSegments(remote, tt.frame, tt.oneByte) }) + if len(tt.frame) == 0 { + client.conn = &responseDeadlineConn{Conn: client.conn, deadlineSet: deadlineSet} + } body, err := client.Exec("test-command") if tt.wantErr != "" { diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml index d902ea995..e2aa9dce4 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml index d85406090..f74e59433 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml index 4d6aadcbb..5fb318214 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml index 95178e56e..be016422a 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: group: gameplane.local @@ -1546,15 +1546,12 @@ spec: tags: description: |- Tags are Tailscale ACL tags requested for the device when it - registers, via `tailscale up --advertise-tags` (tailscaled's - declarative config has no field for tags, and changing the tags of a - registered node needs a fresh login). Each entry is "tag:" or a bare - name, which gets the "tag:" prefix. The tailnet ACL must grant - tagOwners for these tags to the auth key's owner; if the request is - refused, the tunnel pod logs the error and the tunnel runs untagged. - If any entry is invalid, the whole list is logged and ignored and the - device registers untagged. See - tunnel/specs.md. + registers through embedded tsnet. Each entry is "tag:" or a + bare name, which gets the "tag:" prefix. The tailnet policy must grant + tagOwners to the auth key's owner. Invalid or refused tags keep the + tunnel not ready; it never silently registers without requested tags. + Changing tags replaces the Pod and requires a reusable auth key. + See tunnel/specs.md for identity persistence and registration details. items: type: string maxItems: 8 diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml index e5e3e1e3b..b15227625 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml index 5ade28cee..91ec252d1 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml index 6c5dcbf36..8722e927a 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml index cc113e7e8..17d38fc46 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: group: gameplane.local @@ -209,22 +209,15 @@ spec: format: int64 type: integer phase: - allOf: - - enum: - - Pending - - Running - - Completed - - Failed - - Expired - - enum: - - Pending - - Running - - Completed - - Failed - - Expired description: |- Phase is the current lifecycle phase of the capture. Valid values: Pending, Running, Completed, Failed, Expired. + enum: + - Pending + - Running + - Completed + - Failed + - Expired type: string startTime: description: |- diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml index b77cd5c25..37f7a476c 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml index d902ea995..e2aa9dce4 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml index d85406090..f74e59433 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml index 4d6aadcbb..5fb318214 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml index 95178e56e..be016422a 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: group: gameplane.local @@ -1546,15 +1546,12 @@ spec: tags: description: |- Tags are Tailscale ACL tags requested for the device when it - registers, via `tailscale up --advertise-tags` (tailscaled's - declarative config has no field for tags, and changing the tags of a - registered node needs a fresh login). Each entry is "tag:" or a bare - name, which gets the "tag:" prefix. The tailnet ACL must grant - tagOwners for these tags to the auth key's owner; if the request is - refused, the tunnel pod logs the error and the tunnel runs untagged. - If any entry is invalid, the whole list is logged and ignored and the - device registers untagged. See - tunnel/specs.md. + registers through embedded tsnet. Each entry is "tag:" or a + bare name, which gets the "tag:" prefix. The tailnet policy must grant + tagOwners to the auth key's owner. Invalid or refused tags keep the + tunnel not ready; it never silently registers without requested tags. + Changing tags replaces the Pod and requires a reusable auth key. + See tunnel/specs.md for identity persistence and registration details. items: type: string maxItems: 8 diff --git a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml index e5e3e1e3b..b15227625 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml index 5ade28cee..91ec252d1 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml index 6c5dcbf36..8722e927a 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml index cc113e7e8..17d38fc46 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: group: gameplane.local @@ -209,22 +209,15 @@ spec: format: int64 type: integer phase: - allOf: - - enum: - - Pending - - Running - - Completed - - Failed - - Expired - - enum: - - Pending - - Running - - Completed - - Failed - - Expired description: |- Phase is the current lifecycle phase of the capture. Valid values: Pending, Running, Completed, Failed, Expired. + enum: + - Pending + - Running + - Completed + - Failed + - Expired type: string startTime: description: |- diff --git a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml index b77cd5c25..37f7a476c 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: group: gameplane.local diff --git a/docs/tailscale-forwarding-integration.md b/docs/tailscale-forwarding-integration.md new file mode 100644 index 000000000..2874177b5 --- /dev/null +++ b/docs/tailscale-forwarding-integration.md @@ -0,0 +1,150 @@ +# Tailscale forwarding integration + +This procedure needs a disposable Kubernetes installation and a disposable +tailnet with two client devices. It creates real Tailscale devices and sends real +TCP and UDP traffic. It is an integration environment for #722, not evidence +that a live tailnet run has passed. Ordinary CI runs the local socket and +controller regressions without provider credentials. Follow repository policy: +execute this in an authorized integration environment, not a local test run. + +Use the operator and tunnel image built from the same revision. Record their +digests, the revision, client versions, tailnet device IDs, conditions and traffic +results. Do not record auth keys, login URLs or private node state. + +## Provision the disposable environment + +Enable MagicDNS. Create a reusable auth key permitted to request +`tag:gameplane-test`. Configure a tailnet policy with no broad pre-existing allow +rules: permit the approved client to reach that tag on TCP 25565 and UDP 19132. +The second client must have no grant to the tag. For example, replacing the +example email with the approved test user's identity: + +```json +{ + "tagOwners": {"tag:gameplane-test": ["autogroup:admin"]}, + "grants": [{ + "src": ["approved-test-user@example.com"], + "dst": ["tag:gameplane-test"], + "ip": ["tcp:25565", "udp:19132"] + }] +} +``` + +Install this fixture in the disposable Gameplane cluster. Its third TCP port is +deliberately unadvertised. The backend prefixes replies so replacing it can be +distinguished from continuing to reach an old process. + +```yaml +apiVersion: gameplane.local/v1alpha1 +kind: GameTemplate +metadata: + name: tailnet-echo +spec: + displayName: Tailnet echo integration + game: tailnet-echo + version: "1" + image: python:3.14-alpine + consoleMode: none + command: [python, -u, -c] + args: + - | + import socketserver, threading + class TCP(socketserver.BaseRequestHandler): + def handle(self): + while True: + data = self.request.recv(65536) + if not data: return + self.request.sendall(b'backend-v1:' + data) + class UDP(socketserver.BaseRequestHandler): + def handle(self): + data, sock = self.request + sock.sendto(b'backend-v1:' + data, self.client_address) + for port in (25565, 25575): + server = socketserver.ThreadingTCPServer(('0.0.0.0', port), TCP) + threading.Thread(target=server.serve_forever, daemon=True).start() + socketserver.ThreadingUDPServer(('0.0.0.0', 19132), UDP).serve_forever() + ports: + - {name: game, containerPort: 25565, protocol: TCP, advertise: true} + - {name: query, containerPort: 19132, protocol: UDP, advertise: true} + - {name: private, containerPort: 25575, protocol: TCP, advertise: false} +--- +apiVersion: gameplane.local/v1alpha1 +kind: GameServer +metadata: + name: tailnet-echo + namespace: gameplane-games +spec: + templateRef: {name: tailnet-echo} + networking: + expose: ClusterIP + tunnel: + enabled: true + provider: tailscale + credentialsSecretRef: {name: tailnet-echo-tunnel-auth} + tailscale: + hostname: gameplane-forward-test + tags: ["tag:gameplane-test"] +``` + +Provide the reusable auth key through the dashboard's tunnel credential form or +the credential API; these create the required GameServer ownership reference. +Wait for the tunnel Deployment's ready replica and `TunnelReady=True`. Confirm +the admin console shows the requested tag and exact hostname, with no advertised +subnet routes. Record the node ID and tailnet IPs. Remove conflicting stale test +devices if the hostname has acquired a suffix; that state must remain not ready. + +## Exercise the datapath + +On the approved tailnet client, run this with the fixture's MagicDNS name: + +```python +import socket +host = 'gameplane-forward-test' +for kind, port in [(socket.SOCK_STREAM, 25565), (socket.SOCK_DGRAM, 19132)]: + with socket.socket(socket.AF_INET, kind) as client: + client.settimeout(5) + client.connect((socket.gethostbyname(host), port)) + client.sendall(b'game-payload') + assert client.recv(65536) == b'backend-v1:game-payload' +``` + +Repeat with the node's IPv6 tailnet address and `AF_INET6` when the environment +supports IPv6. Run two UDP clients concurrently with distinct payloads; each +must receive only its own reply. TCP 25575 and any other unadvertised port must +fail even from the approved client. Repeat the advertised-port probes from the +denied client; both must time out/fail while the tunnel remains ready. Removing +the approved grant must block new flows too; restore it before continuing. + +Create an unrelated echo Service with a distinguishable prefix in the cluster. +It must not be reachable through this tailnet device on any port, and no route +to its ClusterIP or the cluster's Service/Pod CIDRs may appear on either client. +The local regression also rejects unrelated backing DNS names before listeners +are opened; no incoming packet contains a selectable destination. + +Change the fixture backend prefix to `backend-v2:` and wait for its game Pod +rollout. New TCP connections must receive v2. Stop UDP traffic for over 30 seconds +to expire the old session, then send again and require v2. In the disposable +cluster, delete only the fixture's game Service and wait for the operator to +recreate it; repeat both probes. Existing connections may fail during replacement; +new sessions must resolve the Service again and recover without replacing the +tunnel Pod. + +## Identity, authentication and shutdown outcomes + +| Change | Required outcome | +|---|---| +| Restart the tunnel **container** while retaining the Pod and EmptyDir (use the integration runner's container runtime controls) | Same node ID, hostname and tags reconnect; TCP/UDP recover. | +| Delete the tunnel **Pod**, remove its stale device from the admin console, allow replacement | New identity registers using the reusable key; exact hostname/tags recover and both protocols work. | +| Replace Pod with an expired/revoked key in its credential Secret | Registration never makes the Pod ready and no Tailscale endpoint is advertised. Store a valid reusable key, replace the Pod and require recovery. | +| Request a tag not permitted by tagOwners | Not ready; there must be no untagged fallback device serving traffic. Restore permitted tags and repair credentials/device state, then verify recovery. | +| Rename an existing device to collide with the configured hostname | A suffix assigned to the replacement device cannot make Gameplane advertise the wrong hostname as ready. Resolve the collision and re-register. | +| Disconnect the node from the tailnet/control plane | Readiness turns false after the status/probe intervals; restore connectivity and require recovery. Do not use client ACL denial as a simulation of a disconnected server. | +| Delete/terminate tunnel Pod during active TCP and UDP traffic | Listeners and sessions close, worker shutdown does not hang, and clients reconnect only after the replacement is ready. | + +Remove the GameServer and fixture template, revoke the disposable auth key, and +delete all test devices from the tailnet. Retain only the sanitized result record. + +The implementation uses the official [tsnet APIs](https://tailscale.com/docs/reference/tsnet-server-api). +Its Go 1.26.10 minimum and Go 1.27.3 image builders include the `crypto/x509` +security fix described in the [Go release history](https://go.dev/doc/devel/release) +and [Tailscale security bulletin TS-2026-013](https://tailscale.com/security-bulletins#ts-2026-013). diff --git a/docs/tunnels.md b/docs/tunnels.md index b8ce3fd2a..66f965301 100644 --- a/docs/tunnels.md +++ b/docs/tunnels.md @@ -197,21 +197,27 @@ specify in `spec.networking.tunnel.tailscale.hostname` (or defaults to the GameServer name if left empty). Port 25565 is available to devices on your tailnet. -> **`tags` requires an ACL grant.** `spec.networking.tunnel.tailscale.tags` -> is requested when the device registers with your tailnet: the tunnel pod -> runs `tailscale up --advertise-tags=` once, over the local daemon's -> socket. Tailscale's headless daemon config has no field for tags, and -> changing the tags of a registered device needs a fresh login (see -> `tunnel/specs.md`). The tailnet's ACL must grant `tagOwners` for the -> requested tags to the auth key's owner. If it doesn't, Tailscale refuses -> the request and the tunnel pod logs the error. If the device is not -> running afterwards, the pod tries once more without tags. That untagged -> attempt can fail too (for example with a single-use auth key), in which -> case the error is logged and the device stays logged out. Configure `tagOwners` in your tailnet's ACL before setting -> `tags` here. Each tag is `tag:` or a bare ``, where the name -> starts with a letter and holds only letters, digits and `-`. If any tag is -> invalid, the whole list is logged and ignored and the device registers -> untagged. +The embedded Tailscale node forwards every advertised TCP and UDP port to this +GameServer's backing Service. It does not provide a subnet route or expose +unadvertised ports. `TunnelReady` requires an online device and active forwarding +listeners. Tailnet grants still govern each player's access; readiness is not a +test of an individual grant or of the sleeping game's application health. + +Requested tags require a matching `tagOwners` policy. A bare tag name receives +the `tag:` prefix. Invalid or denied tags keep the tunnel not ready; it never +silently falls back to an untagged identity. + +Container restarts retain identity in a Pod volume. Replacing the Pod, changing +its tags, or upgrading from the older tailscaled implementation re-registers the +device and requires a valid reusable auth key. Remove stale devices from the +tailnet admin console so the configured MagicDNS hostname remains available. +If Tailscale assigns a suffixed name because of a conflict, Gameplane keeps the +advertised endpoint not ready until the conflict is resolved. Enable MagicDNS. + +Upgrade the operator and tunnel images together: the forwarding contract now +carries each port's TCP/UDP protocol. See the +[disposable tailnet integration procedure](tailscale-forwarding-integration.md) +for traffic, access-policy and restart checks. The `credentialsSecretRef.name` is the name that the dashboard or API write after you provide the credential. diff --git a/go.work b/go.work index fa6b1bb44..99fe792db 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.0 +go 1.27.3 use ( ./agent diff --git a/go.work.sum b/go.work.sum index 730a9a159..da1f24437 100644 --- a/go.work.sum +++ b/go.work.sum @@ -7,24 +7,144 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-202602092021 buf.build/go/protovalidate v0.14.0/go.mod h1:+F/oISho9MO7gJQNYC2VWLzcO1fTPmaTA08SDYJZncA= buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI= +cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k= +cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI= +cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc= +cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk= +cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q= +cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0= +cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM= +cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y= +cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8= +cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M= +cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI= +cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg= +cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw= +cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY= +cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM= +cloud.google.com/go/bigtable v1.50.0/go.mod h1:RTannV5mvoJM8KscLTfRYMPo84u9/j+C3PSyYJGf5Ic= +cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk= +cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss= +cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8= +cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY= +cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA= +cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk= +cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE= +cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ= +cloud.google.com/go/container v1.53.0/go.mod h1:SBOylKhlKYCBFs/8kz2yqRdUW5ctVNHs82JKOTjrB9s= +cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ= +cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM= +cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ= +cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk= +cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc= +cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q= +cloud.google.com/go/dataplex v1.35.0/go.mod h1:B7AFwXU1u3sp7FVQ3IFYnQguGTycJS2mF1voE0lLe1o= +cloud.google.com/go/dataproc/v2 v2.23.0/go.mod h1:dOzSynzBm7TBf9nIxmJxKAQt5EpdNPcNJomYfbpPhm4= +cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs= +cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So= +cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ= +cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U= +cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw= +cloud.google.com/go/dlp v1.36.0/go.mod h1:UW92dBhxvqkSKLct+Ril7Y9B4CanS5VLuDwlTGVA9VQ= +cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c= +cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk= +cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE= +cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84= +cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A= +cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU= +cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM= +cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= +cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0= +cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY= +cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ= +cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0= +cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM= +cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0= +cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ= +cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04= +cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U= +cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA= +cloud.google.com/go/maps v1.36.0/go.mod h1:Ly0sd/0G1MgKuWpGc2vCBjNZ+fc8iRHzcBWJqrw7Xao= +cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ= +cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg= +cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc= +cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= +cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg= +cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU= +cloud.google.com/go/networksecurity v0.17.0/go.mod h1:NlMistWENBCFt1v748gUn4v9Rk5AVCTDTFs1VSE3JUg= +cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM= +cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8= +cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ= +cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ= +cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk= +cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc= +cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k= +cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4= +cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8= cloud.google.com/go/profiler v0.4.3/go.mod h1:3xFodugWfPIQZWFcXdUmfa+yTiiyQ8fWrdT+d2Sg4J0= cloud.google.com/go/profiler v0.6.0/go.mod h1:cJV7Qfj0o9PAC7q/xQTkM6qn2FO9So3TFk4P5O5yLis= cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w= +cloud.google.com/go/pubsub v1.51.0/go.mod h1:NERXf11sd82UV3VnflcUj8POIyQUXT/QwrKlxD8di/I= cloud.google.com/go/pubsub/v2 v2.3.0/go.mod h1:O5f0KHG9zDheZAd3z5rlCRhxt2JQtB+t/IYLKK3Bpvw= +cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA= cloud.google.com/go/pubsub/v2 v2.6.0/go.mod h1:4anqvV/w8Pcgu2tO0qr2XgsF3GXHowzryfQ5gOnVmWY= +cloud.google.com/go/pubsub/v2 v2.6.1/go.mod h1:1y2lZnKfUFPZz0PU4YmXyk4lA11+xmYA42zbC32RkxQ= +cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI= +cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs= +cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k= +cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU= +cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw= +cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE= +cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw= +cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk= +cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU= +cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA= +cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU= cloud.google.com/go/security v1.19.2/go.mod h1:KXmf64mnOsLVKe8mk/bZpU1Rsvxqc0Ej0A6tgCeN93w= cloud.google.com/go/security v1.25.0/go.mod h1:xKPO7XBfUtgjfzPJeznEhI0gp/ZRJt/ZbWtuMYMeUDk= +cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U= +cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk= +cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY= cloud.google.com/go/spanner v1.86.1/go.mod h1:bbwCXbM+zljwSPLZ44wZOdzcdmy89hbUGmM/r9sD0ws= cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0= +cloud.google.com/go/spanner v1.92.0/go.mod h1:rCDPfWXNX0h+t484r+crCEaaMKbJfoWkHRDKU3H3+oY= +cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8= cloud.google.com/go/storage v1.57.1/go.mod h1:329cwlpzALLgJuu8beyJ/uvQznDHpa2U5lGjWednkzg= cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4= +cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y= +cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk= +cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU= +cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk= +cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4= +cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ= +cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY= +cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0= +cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8= +cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc= +cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8= +cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g= codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU= codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw= @@ -56,7 +176,7 @@ github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdE github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= @@ -73,6 +193,7 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= @@ -148,6 +269,8 @@ github.com/cavaliercoder/badio v0.0.0-20160213150051-ce5280129e9e/go.mod h1:V284 github.com/cavaliercoder/go-rpm v0.0.0-20200122174316-8cb9fd9c31a8/go.mod h1:AZIh1CCnMrcVm6afFf96PBvE2MRpWFco91z8ObJtgDY= github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/chainguard-dev/clog v1.7.0/go.mod h1:4+WFhRMsGH79etYXY3plYdp+tCz/KCkU8fAr0HoaPvs= github.com/chainguard-dev/clog v1.8.0/go.mod h1:5MQOZi+Iu7fV7GcJG8ag8rCB5elEOpqRMKEASgnGVdo= @@ -170,8 +293,10 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= @@ -184,6 +309,7 @@ github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3 github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= github.com/coreos/go-oidc v2.3.0+incompatible h1:+5vEsrgprdLjjQ9FzIKAzQz1wwPD+83hQRfUIPh7rO0= github.com/coreos/go-oidc v2.3.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-oidc v2.5.0+incompatible h1:6W0vGJR3Tu0r0PwfmjOrRZSlfxeEln8dsejt3ZWIvwo= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= @@ -208,6 +334,9 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/eggsampler/acme/v3 v3.6.2/go.mod h1:/qh0rKC/Dh7Jj+p4So7DbWmFNzC4dpcpK53r226Fhuo= github.com/eggsampler/acme/v3 v3.8.1/go.mod h1:/qh0rKC/Dh7Jj+p4So7DbWmFNzC4dpcpK53r226Fhuo= github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= @@ -215,10 +344,14 @@ github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1 github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= github.com/flynn/go-docopt v0.0.0-20140912013429-f6dd2ebbb31e/go.mod h1:HyVoz1Mz5Co8TFO8EupIdlcpwShBmY98dkT2xeHkvEI= github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE= @@ -237,6 +370,7 @@ github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3I github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= @@ -289,10 +423,16 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= @@ -306,17 +446,29 @@ github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sC github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= github.com/google/addlicense v1.1.1/go.mod h1:Sm/DHu7Jk+T5miFHHehdIjbi4M5+dJDRS3Cq0rncIxA= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gopacket/gopacket v1.6.1 h1:S19Ok/KVGDFNHVW2uCva5U0vZ+uHqiZQdxteL50v6Ak= github.com/gopacket/gopacket v1.6.1/go.mod h1:i3NaGaqfoWKAr1+g7qxEdWsmfT+MXuWkAe9+THv8LME= github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= @@ -445,7 +597,9 @@ github.com/oleiade/reflections v1.1.0/go.mod h1:mCxx0QseeVCHs5Um5HhJeCKVC7AwS8kO github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk= github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= +github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/open-policy-agent/opa v1.8.0/go.mod h1:vOVZuIJQISnaYcZtQ58yTDkVCp1FmGPwK43pO9qPDqM= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= @@ -460,6 +614,9 @@ github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQ github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= github.com/prometheus/otlptranslator v0.0.2/go.mod h1:P8AwMgdD7XEr6QRUJ2QWLpiAZTgTE2UYgjlu3svompI= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.21.0/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= @@ -588,6 +745,7 @@ go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1 go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I= go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= +go.einride.tech/aip v0.83.0/go.mod h1:E8+wdTApA70odnpFzJgsGogHozC2JCIhFJBKPr8bVig= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= @@ -626,9 +784,15 @@ go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOh go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= @@ -636,12 +800,23 @@ go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4 go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o= go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= @@ -662,11 +837,20 @@ golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbV golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee h1:WG0RUwxtNT4qqaXX3DPA8zHFNm/D9xaBpxzHt1WcA/E= @@ -680,8 +864,14 @@ golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -699,13 +889,16 @@ golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -715,8 +908,10 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -736,8 +931,10 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= @@ -745,6 +942,7 @@ golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/ golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= @@ -762,10 +960,16 @@ golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7 h1:EBZoQjiKKPaLbPrbpssUfuHtwM6KV/vb4U85g/cigFY= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -778,21 +982,36 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/plot v0.15.2/go.mod h1:DX+x+DWso3LTha+AdkJEv5Txvi+Tql3KAGkehP0/Ubg= +google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/api v0.285.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= +google.golang.org/api v0.287.1/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= +google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20260706201446-f0a921348800/go.mod h1:MS5Wgu4uvm+nhCBwXzKEpYKPawaaNNUT7WfgpH44YVQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= @@ -800,23 +1019,50 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260727163830-6c54dddc4772/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc= google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk= +k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE= +k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0= k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= @@ -827,6 +1073,8 @@ k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppK mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/controller-tools v0.22.0 h1:eG3FAVja/KnlXKIWg95udIFz1cMyAtMjP11cqBh3t+k= +sigs.k8s.io/controller-tools v0.22.0/go.mod h1:VizwUStoZK7rReCj704czGGrB7mLxXTiJSJt7wN5ilI= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/release-utils v0.12.2/go.mod h1:Ab9Lb/FpGUw4lUXj1QYbUcF2TRzll+GS7Md54W1G7sA= sigs.k8s.io/release-utils v0.12.4/go.mod h1:Tc3iM9DVM3W9oJu/6rEI+LnREuhy8lZ7wInQhRBtUoo= diff --git a/hack/sync-chart-crds.sh b/hack/sync-chart-crds.sh index 98ba9ff18..a86fe3f5a 100755 --- a/hack/sync-chart-crds.sh +++ b/hack/sync-chart-crds.sh @@ -48,19 +48,21 @@ if command -v sha256sum >/dev/null 2>&1; then else sha256() { shasum -a 256; } fi -hash=$(cat "${files[@]}" | sha256 | cut -d' ' -f1) +# Git may check out these text files with CRLF on Windows. Hash and copy the +# canonical LF representation so the stamp matches Linux code generation. +hash=$(sed $'s/\r$//' "${files[@]}" | sha256 | cut -d' ' -f1) for f in "${files[@]}"; do # controller-gen always emits exactly one top-level metadata.annotations # block (two-space indent); schema-level `annotations:` keys sit deeper. - if [ "$(grep -c '^ annotations:$' "$f")" != 1 ]; then + if [ "$(sed $'s/\r$//' "$f" | grep -c '^ annotations:$')" != 1 ]; then echo "sync-chart-crds: $f has no single metadata.annotations block to stamp" >&2 exit 1 fi base=$(basename "$f") for dir in charts/gameplane/charts/gameplane-crds/crds charts/gameplane/crd-manifests; do awk -v line=" ${annotation}: ${hash}" ' - { print } + { sub(/\r$/, ""); print } /^ annotations:$/ && !done { print line; done = 1 } ' "$f" > "$dir/$base" done diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index 059470fd2..187cf2b88 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -595,6 +595,10 @@ "defaultMessage": "Unknown tunnel provider ''{provider}''.", "description": "TunnelReady condition when the provider name is not supported." }, + "condition.tunnelready.unsupportedprotocol": { + "defaultMessage": "Tailscale forwards only advertised TCP and UDP ports. Remove unsupported protocols from the advertised ports.", + "description": "TunnelReady condition when a Tailscale template advertises a protocol other than TCP or UDP." + }, "condition.unquiesced.failed": { "defaultMessage": "The server could not be resumed after the backup. Its world may still have auto-save turned off.", "description": "Unquiesced condition when the unfreeze failed." diff --git a/operator/api/v1alpha1/gameserver_types.go b/operator/api/v1alpha1/gameserver_types.go index 654cba6ed..df2a65660 100644 --- a/operator/api/v1alpha1/gameserver_types.go +++ b/operator/api/v1alpha1/gameserver_types.go @@ -460,15 +460,12 @@ type TailscaleTunnelSpec struct { Hostname string `json:"hostname,omitempty"` // Tags are Tailscale ACL tags requested for the device when it - // registers, via `tailscale up --advertise-tags` (tailscaled's - // declarative config has no field for tags, and changing the tags of a - // registered node needs a fresh login). Each entry is "tag:" or a bare - // name, which gets the "tag:" prefix. The tailnet ACL must grant - // tagOwners for these tags to the auth key's owner; if the request is - // refused, the tunnel pod logs the error and the tunnel runs untagged. - // If any entry is invalid, the whole list is logged and ignored and the - // device registers untagged. See - // tunnel/specs.md. + // registers through embedded tsnet. Each entry is "tag:" or a + // bare name, which gets the "tag:" prefix. The tailnet policy must grant + // tagOwners to the auth key's owner. Invalid or refused tags keep the + // tunnel not ready; it never silently registers without requested tags. + // Changing tags replaces the Pod and requires a reusable auth key. + // See tunnel/specs.md for identity persistence and registration details. // +kubebuilder:validation:MaxItems=8 // +optional Tags []string `json:"tags,omitempty"` diff --git a/operator/config/crd/gameplane.local_backups.yaml b/operator/config/crd/gameplane.local_backups.yaml index f96731731..9b3af89a3 100644 --- a/operator/config/crd/gameplane.local_backups.yaml +++ b/operator/config/crd/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_backupschedules.yaml b/operator/config/crd/gameplane.local_backupschedules.yaml index ac32183ae..2011ca4cd 100644 --- a/operator/config/crd/gameplane.local_backupschedules.yaml +++ b/operator/config/crd/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_clusters.yaml b/operator/config/crd/gameplane.local_clusters.yaml index fffd585ac..285dcc73d 100644 --- a/operator/config/crd/gameplane.local_clusters.yaml +++ b/operator/config/crd/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_gameservers.yaml b/operator/config/crd/gameplane.local_gameservers.yaml index 4be670ebc..4616a25b0 100644 --- a/operator/config/crd/gameplane.local_gameservers.yaml +++ b/operator/config/crd/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: group: gameplane.local @@ -1545,15 +1545,12 @@ spec: tags: description: |- Tags are Tailscale ACL tags requested for the device when it - registers, via `tailscale up --advertise-tags` (tailscaled's - declarative config has no field for tags, and changing the tags of a - registered node needs a fresh login). Each entry is "tag:" or a bare - name, which gets the "tag:" prefix. The tailnet ACL must grant - tagOwners for these tags to the auth key's owner; if the request is - refused, the tunnel pod logs the error and the tunnel runs untagged. - If any entry is invalid, the whole list is logged and ignored and the - device registers untagged. See - tunnel/specs.md. + registers through embedded tsnet. Each entry is "tag:" or a + bare name, which gets the "tag:" prefix. The tailnet policy must grant + tagOwners to the auth key's owner. Invalid or refused tags keep the + tunnel not ready; it never silently registers without requested tags. + Changing tags replaces the Pod and requires a reusable auth key. + See tunnel/specs.md for identity persistence and registration details. items: type: string maxItems: 8 diff --git a/operator/config/crd/gameplane.local_gametemplates.yaml b/operator/config/crd/gameplane.local_gametemplates.yaml index 13d8a1fa5..c6428c858 100644 --- a/operator/config/crd/gameplane.local_gametemplates.yaml +++ b/operator/config/crd/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_modules.yaml b/operator/config/crd/gameplane.local_modules.yaml index 3f35114f9..9c06cec82 100644 --- a/operator/config/crd/gameplane.local_modules.yaml +++ b/operator/config/crd/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_modulesources.yaml b/operator/config/crd/gameplane.local_modulesources.yaml index 879583947..a65b30cdd 100644 --- a/operator/config/crd/gameplane.local_modulesources.yaml +++ b/operator/config/crd/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_networkcaptures.yaml b/operator/config/crd/gameplane.local_networkcaptures.yaml index 76bc066a7..40bd55ecf 100644 --- a/operator/config/crd/gameplane.local_networkcaptures.yaml +++ b/operator/config/crd/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: group: gameplane.local @@ -208,22 +208,15 @@ spec: format: int64 type: integer phase: - allOf: - - enum: - - Pending - - Running - - Completed - - Failed - - Expired - - enum: - - Pending - - Running - - Completed - - Failed - - Expired description: |- Phase is the current lifecycle phase of the capture. Valid values: Pending, Running, Completed, Failed, Expired. + enum: + - Pending + - Running + - Completed + - Failed + - Expired type: string startTime: description: |- diff --git a/operator/config/crd/gameplane.local_restores.yaml b/operator/config/crd/gameplane.local_restores.yaml index 7c8a9e530..4984f6817 100644 --- a/operator/config/crd/gameplane.local_restores.yaml +++ b/operator/config/crd/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: group: gameplane.local diff --git a/operator/hack/condition-ids/main.go b/operator/hack/condition-ids/main.go index e644cf041..41cefbf94 100644 --- a/operator/hack/condition-ids/main.go +++ b/operator/hack/condition-ids/main.go @@ -121,6 +121,7 @@ var rows = []row{ {types: []string{"AddressAssignment"}, reason: "Assigned", english: "{assignedFrom, select, pool {Address {address} assigned from pool ''{pool}''.} other {Address {address} assigned.}}", description: "AddressAssignment condition once an address is assigned. assignedFrom is pool or default."}, // GameServer: tunnel and other + {types: []string{"TunnelReady"}, reason: "UnsupportedProtocol", english: "Tailscale forwards only advertised TCP and UDP ports. Remove unsupported protocols from the advertised ports.", description: "TunnelReady condition when a Tailscale template advertises a protocol other than TCP or UDP."}, {types: []string{"TunnelReady"}, reason: "InvalidConfig", english: "{provider, select, none {The tunnel is enabled but the server has no tunnel configuration.} other {The ''{provider}'' tunnel provider is selected but its configuration is missing.}}", description: "TunnelReady condition for an incomplete tunnel setup. provider is the selected provider, or none."}, {types: []string{"TunnelReady"}, reason: "DeploymentNotReady", english: "Waiting for the tunnel deployment to become ready.", description: "TunnelReady condition while the tunnel pod is not ready."}, {types: []string{"TunnelReady"}, reason: "PortNotMapped", english: "No frp remote port is configured for: {ports}.", description: "TunnelReady condition for frp when advertised ports have no remote port. ports is a comma-separated list of port names."}, diff --git a/operator/internal/controller/gameserver_status.go b/operator/internal/controller/gameserver_status.go index 54393de0b..f0b33fb4d 100644 --- a/operator/internal/controller/gameserver_status.go +++ b/operator/internal/controller/gameserver_status.go @@ -260,8 +260,19 @@ func (r *GameServerReconciler) reconcileStatus( // Connection card reads the address players actually use. if svcExists { gs.Status.Endpoints = endpointsFromService(&svc, addrPlan) + } else { + // A deleted/replacing Service must not preserve a stale tailnet endpoint + // when the forwarding deployment has since lost readiness. + var retained []gameplanev1alpha1.GameServerEndpoint + for _, endpoint := range gs.Status.Endpoints { + if endpoint.TunnelProvider != "tailscale" { + retained = append(retained, endpoint) + } + } + gs.Status.Endpoints = retained } - if tunnelPlan.wantTunnel && len(tunnelPlan.endpoints) > 0 { + if tunnelPlan.wantTunnel && len(tunnelPlan.endpoints) > 0 && + (gs.Spec.Networking.Tunnel.Provider != "tailscale" || meta.IsStatusConditionTrue(gs.Status.Conditions, "TunnelReady")) { gs.Status.Endpoints = append(tunnelPlan.endpoints, gs.Status.Endpoints...) } if phase == gameplanev1alpha1.GameServerPhaseRunning { @@ -1105,6 +1116,12 @@ func computeTunnelConditions( } // Tunnel Deployment not yet created or not ready. + if tunnel.Provider == "tailscale" && len(plan.unsupportedPorts) > 0 { + tunnelReady.Status = metav1.ConditionFalse + tunnelReady.Reason = "UnsupportedProtocol" + tunnelReady.Message = "Tailscale supports only TCP and UDP; unsupported advertised ports: " + strings.Join(plan.unsupportedPorts, ", ") + return upsertCondition(conds, tunnelReady) + } if tunnelDep == nil { tunnelReady.Status = metav1.ConditionFalse tunnelReady.Reason = "DeploymentNotReady" @@ -1167,10 +1184,23 @@ func computeTunnelConditions( return conds } - // Deployment is ready and credentials are configured. + // A legacy image merely registering a device cannot satisfy readiness. + forwardingProbe := false + for _, container := range tunnelDep.Spec.Template.Spec.Containers { + if container.Name == "tunnel" && container.ReadinessProbe != nil && container.ReadinessProbe.HTTPGet != nil { + probe := container.ReadinessProbe.HTTPGet + forwardingProbe = probe.Path == "/readyz" && probe.Port.IntValue() == 8091 + } + } + if !forwardingProbe || tunnelDep.Status.ObservedGeneration < tunnelDep.Generation || tunnelDep.Status.UpdatedReplicas == 0 { + tunnelReady.Status = metav1.ConditionFalse + tunnelReady.Reason = "DeploymentNotReady" + tunnelReady.Message = "waiting for the Tailscale forwarding deployment and its readiness probe" + return upsertCondition(conds, tunnelReady) + } tunnelReady.Status = metav1.ConditionTrue tunnelReady.Reason = "Ready" - tunnelReady.Message = "tunnel deployment ready and authenticated" + tunnelReady.Message = "tailnet online and all advertised TCP/UDP forwarding listeners active; client access remains subject to tailnet policy" case "playit": if tunnel.Playit == nil { diff --git a/operator/internal/controller/gameserver_tailscale_test.go b/operator/internal/controller/gameserver_tailscale_test.go new file mode 100644 index 000000000..1653fccf2 --- /dev/null +++ b/operator/internal/controller/gameserver_tailscale_test.go @@ -0,0 +1,48 @@ +package controller + +import ( + "context" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + gameplanev1alpha1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" +) + +func TestTailscaleAdvertisedProtocolsAndReadiness(t *testing.T) { + tmpl := &gameplanev1alpha1.GameTemplate{Spec: gameplanev1alpha1.GameTemplateSpec{Ports: []gameplanev1alpha1.GamePort{ + {Name: "game", ContainerPort: 25565, Advertise: true}, + {Name: "query", ContainerPort: 19132, Protocol: corev1.ProtocolUDP, Advertise: true}, + {Name: "rcon", ContainerPort: 25575, Protocol: corev1.ProtocolTCP}, + }}} + if got := buildTailscalePortsConfig(tmpl); got != "game:25565:tcp,query:19132:udp" { + t.Fatalf("advertised mapping: %s", got) + } + gs := &gameplanev1alpha1.GameServer{ObjectMeta: metav1.ObjectMeta{Name: "game", Namespace: "games"}, Spec: gameplanev1alpha1.GameServerSpec{Networking: gameplanev1alpha1.GameServerNetworking{Tunnel: &gameplanev1alpha1.GameServerTunnel{Enabled: true, Provider: "tailscale", Tailscale: &gameplanev1alpha1.TailscaleTunnelSpec{}, CredentialsSecretRef: &gameplanev1alpha1.SecretNameRef{Name: "auth"}}}}} + r := &GameServerReconciler{} + plan := r.planTunnel(context.Background(), gs, tmpl) + dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Generation: 2}, Status: appsv1.DeploymentStatus{ReadyReplicas: 1, UpdatedReplicas: 1, ObservedGeneration: 2}} + assert := func(want metav1.ConditionStatus) { + t.Helper() + c := meta.FindStatusCondition(computeTunnelConditions(gs, plan, dep), "TunnelReady") + if c == nil || c.Status != want { + t.Fatalf("TunnelReady=%+v, want %s", c, want) + } + } + assert(metav1.ConditionFalse) // A registered legacy deployment has no forwarding probe. + dep.Spec.Template.Spec.Containers = []corev1.Container{{Name: "tunnel", ReadinessProbe: &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/readyz", Port: intstr.FromInt(8091)}}}}} + assert(metav1.ConditionTrue) + dep.Status.ObservedGeneration = 1 + assert(metav1.ConditionFalse) + dep.Status.ObservedGeneration = 2 + dep.Status.ReadyReplicas = 0 + assert(metav1.ConditionFalse) + dep.Status.ReadyReplicas = 1 + tmpl.Spec.Ports[0].Protocol = corev1.ProtocolSCTP + plan = r.planTunnel(context.Background(), gs, tmpl) + assert(metav1.ConditionFalse) +} diff --git a/operator/internal/controller/gameserver_tunnel.go b/operator/internal/controller/gameserver_tunnel.go index a950292b2..0168e9e88 100644 --- a/operator/internal/controller/gameserver_tunnel.go +++ b/operator/internal/controller/gameserver_tunnel.go @@ -52,7 +52,8 @@ type tunnelPlan struct { endpoints []gameplanev1alpha1.GameServerEndpoint // noMapping is the set of advertised port names that have no frp remote-port mapping. // Used only by frp to report a helpful condition reason. - noMapping []string + noMapping []string + unsupportedPorts []string } // planTunnel is the single source of truth for the tunnel pod's existence @@ -74,6 +75,7 @@ func (r *GameServerReconciler) planTunnel( tunnel := gs.Spec.Networking.Tunnel endpoints := make([]gameplanev1alpha1.GameServerEndpoint, 0) var noMapping []string + var unsupportedPorts []string switch tunnel.Provider { case "frp": @@ -128,6 +130,10 @@ func (r *GameServerReconciler) planTunnel( if !p.Advertise { continue } + if p.Protocol != "" && p.Protocol != corev1.ProtocolTCP && p.Protocol != corev1.ProtocolUDP { + unsupportedPorts = append(unsupportedPorts, p.Name) + continue + } ep := gameplanev1alpha1.GameServerEndpoint{ Name: p.Name, @@ -152,9 +158,10 @@ func (r *GameServerReconciler) planTunnel( } return tunnelPlan{ - wantTunnel: true, - endpoints: endpoints, - noMapping: noMapping, + wantTunnel: true, + endpoints: endpoints, + noMapping: noMapping, + unsupportedPorts: unsupportedPorts, } } @@ -199,6 +206,11 @@ func (r *GameServerReconciler) reconcileTunnel( refusedSecret = "" replicas := int32(1) dep.Spec.Replicas = &replicas + dep.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RollingUpdateDeploymentStrategyType} + if tunnel.Provider == "tailscale" { + // Avoid two tailnet devices racing for the same hostname on rollout. + dep.Spec.Strategy.Type = appsv1.RecreateDeploymentStrategyType + } dep.Spec.Selector = &metav1.LabelSelector{ MatchLabels: map[string]string{ tunnelLabel: tunnelValue, @@ -388,13 +400,20 @@ func (r *GameServerReconciler) reconcileTunnel( } } + var readiness *corev1.Probe + if tunnel.Provider == "tailscale" { + volumes = append(volumes, corev1.Volume{Name: "tailscale-state", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}) + volumeMounts = append(volumeMounts, corev1.VolumeMount{Name: "tailscale-state", MountPath: "/var/lib/gameplane-tailscale"}) + readiness = &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/readyz", Port: intstr.FromInt(8091)}}, PeriodSeconds: 5, TimeoutSeconds: 2, FailureThreshold: 1} + } dep.Spec.Template.Spec.Volumes = volumes dep.Spec.Template.Spec.Containers = []corev1.Container{{ - Name: "tunnel", - Image: image, - Env: envVars, - Ports: ports, - VolumeMounts: volumeMounts, + Name: "tunnel", + Image: image, + Env: envVars, + Ports: ports, + VolumeMounts: volumeMounts, + ReadinessProbe: readiness, SecurityContext: &corev1.SecurityContext{ RunAsNonRoot: &nonRoot, RunAsUser: &uid, @@ -694,14 +713,18 @@ func buildFrpRemotePortsConfig(frp *gameplanev1alpha1.FrpTunnelSpec, tmpl *gamep } // buildTailscalePortsConfig constructs the BACKING_SERVICE_PORTS env var for Tailscale. -// Format: "port_name:port,..." e.g. "java:25565,bedrock:19133" +// Format: "port_name:port:protocol,..." e.g. "java:25565:tcp,bedrock:19133:udp". func buildTailscalePortsConfig(tmpl *gameplanev1alpha1.GameTemplate) string { var entries []string for _, p := range tmpl.Spec.Ports { if !p.Advertise { continue } - entries = append(entries, fmt.Sprintf("%s:%d", p.Name, p.ContainerPort)) + protocol := strings.ToLower(string(p.Protocol)) + if protocol == "" { + protocol = "tcp" + } + entries = append(entries, fmt.Sprintf("%s:%d:%s", p.Name, p.ContainerPort, protocol)) } if len(entries) == 0 { return "" diff --git a/operator/internal/controller/gameserver_tunnel_envtest_test.go b/operator/internal/controller/gameserver_tunnel_envtest_test.go index d86582fb9..2447b2ee1 100644 --- a/operator/internal/controller/gameserver_tunnel_envtest_test.go +++ b/operator/internal/controller/gameserver_tunnel_envtest_test.go @@ -109,6 +109,32 @@ func TestTunnelReconciliation_CredentialKeyProjection(t *testing.T) { } // Verify the Secret volume has Items projection with only "authKey" + if dep.Spec.Strategy.Type != appsv1.RecreateDeploymentStrategyType || dep.Spec.Strategy.RollingUpdate != nil { + t.Fatalf("Tailscale must recreate its single identity: %+v", dep.Spec.Strategy) + } + container := dep.Spec.Template.Spec.Containers[0] + if container.ReadinessProbe == nil || container.ReadinessProbe.HTTPGet == nil || container.ReadinessProbe.HTTPGet.Path != "/readyz" || container.ReadinessProbe.HTTPGet.Port.IntValue() != 8091 { + t.Fatalf("Tailscale lacks forwarding readiness probe: %+v", container.ReadinessProbe) + } + stateVolume, stateMount, protocolEnv := false, false, false + for _, volume := range dep.Spec.Template.Spec.Volumes { + if volume.Name == "tailscale-state" && volume.EmptyDir != nil { + stateVolume = true + } + } + for _, mount := range container.VolumeMounts { + if mount.Name == "tailscale-state" && mount.MountPath == "/var/lib/gameplane-tailscale" { + stateMount = true + } + } + for _, env := range container.Env { + if env.Name == "BACKING_SERVICE_PORTS" && env.Value == "game:25565:tcp" { + protocolEnv = true + } + } + if !stateVolume || !stateMount || !protocolEnv { + t.Fatal("Tailscale lost persistent pod state or advertised protocol contract") + } if len(dep.Spec.Template.Spec.Volumes) == 0 { t.Fatal("no volumes in tunnel pod template") } diff --git a/operator/specs.md b/operator/specs.md index c7aea8225..8759da92d 100644 --- a/operator/specs.md +++ b/operator/specs.md @@ -134,6 +134,7 @@ Nine CRD kinds under `gameplane.local/v1alpha1`: Primary reconcilers register with the manager in `cmd/main.go` and handle CRD lifecycle: ### GameServerReconciler +- **Tailscale forwarding (#722):** the tunnel receives advertised `name:port:protocol` mappings and forwards only to `..svc` through embedded tsnet. Its Deployment uses Recreate and a per-Pod EmptyDir for identity across container restarts. `/readyz` on 8091 requires an online tailnet identity and every TCP/UDP listener. TunnelReady additionally requires the forwarding probe, current Deployment observedGeneration and an updated ready replica; unsupported protocols report InvalidConfig. Tailscale endpoints are withheld while not ready. Pod replacement requires a reusable auth key and an available MagicDNS hostname. No extra privileges or Kubernetes RBAC are added. - **Responsibility:** Reconcile GameServer → StatefulSet, Service, Config ConfigMap, PVC, NetworkPolicy. - **Template recovery:** A missing template reports Failed and retries every 15 seconds. Template creation or spec changes enqueue referencing servers immediately, so a GameServer applied before its Module/template recovers without a manual edit; template status updates do not enqueue servers. - **Key functions:** diff --git a/specs/done_018-v0-3-release-readiness/OPEN-DECISIONS.md b/specs/done_018-v0-3-release-readiness/OPEN-DECISIONS.md index 5401d5a14..effd4cb5b 100644 --- a/specs/done_018-v0-3-release-readiness/OPEN-DECISIONS.md +++ b/specs/done_018-v0-3-release-readiness/OPEN-DECISIONS.md @@ -1,5 +1,11 @@ # Open Decisions: v0.3 Release Readiness Audit +### OD-031: Tailscale game forwarding (F-173) — RESOLVED 2026-10-21 + +Decision (maintainer reply on #722): embed `tailscale.com/tsnet` and forward both TCP and UDP from advertised tailnet listeners to the GameServer's backing Service. This explicitly ends the tunnel module's stdlib-only dependency policy. Device registration alone cannot establish readiness; the same change must provide actual tailnet/listener health. The previous Known Gaps entry referred to a decision that had never been recorded. + +The bridge opens only advertised protocol/port pairs, never a subnet route. Tailnet grants/ACLs govern peer access. The node uses the existing unprivileged container context, with no NET_ADMIN or TUN device. A per-Pod EmptyDir retains identity across container restarts; Pod replacement re-registers with a reusable auth key and requires an available MagicDNS hostname. No durable PVC is introduced. Invalid or refused requested tags remain not ready rather than falling back to an untagged device. UDP queues, session counts, idle lifetimes and shutdown are bounded. See `tunnel/specs.md` and `docs/tailscale-forwarding-integration.md` for the implementation contract and integration procedure. + These values are unsettled. They are not contracts; resolve them during `/speckit-clarify` or `/speckit-plan`. ## OD-001: How the release candidate is deployed onto kubelab — RESOLVED 2026-09-23 diff --git a/test/e2e/api_events_roles_scope_e2e_test.go b/test/e2e/api_events_roles_scope_e2e_test.go index 2ab19b2b4..4c3b388c0 100644 --- a/test/e2e/api_events_roles_scope_e2e_test.go +++ b/test/e2e/api_events_roles_scope_e2e_test.go @@ -12,6 +12,9 @@ import ( "strings" "testing" "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ) // TestAPI_EventStreamAndRoleEdits_FollowCallerPermissions checks two @@ -64,19 +67,37 @@ func TestAPI_EventStreamAndRoleEdits_FollowCallerPermissions(t *testing.T) { applyBusyboxTemplate(t, tmpl) createGameServerViaAPI(t, admin, ns, gsName, tmpl) + // /events starts at each collection's current resourceVersion. Drive real + // updates throughout both streams instead of depending on initial replay + // or other parallel tests to happen to mutate a readable resource kind. + changes := func(ctx context.Context) error { + patch, err := json.Marshal(map[string]any{"metadata": map[string]any{"annotations": map[string]string{ + "gameplane.local/e2e-event-sequence": time.Now().UTC().Format(time.RFC3339Nano), + }}}) + if err != nil { + return fmt.Errorf("encode event trigger: %w", err) + } + if _, err := envInstance.Dyn.Resource(gameTemplateGVR).Patch(ctx, tmpl, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil { + return fmt.Errorf("trigger template event: %w", err) + } + if _, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Patch(ctx, gsName, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil { + return fmt.Errorf("trigger server event: %w", err) + } + return nil + } user := envInstance.APIClient(t, userName, userPW) defer user.Close() t.Run("admin event stream includes templates", func(t *testing.T) { - kinds, found := streamEventKinds(t, admin, ns, func(kind, _ string) bool { return kind == "templates" }, 0) + kinds, found := streamEventKinds(t, admin, ns, func(kind, name string) bool { return kind == "templates" && name == tmpl }, 0, changes) if !found { t.Fatalf("admin stream sent no templates event; kinds=%v", kinds) } }) t.Run("event stream sends only kinds the caller may read", func(t *testing.T) { - kinds, found := streamEventKinds(t, user, ns, func(kind, name string) bool { return kind == "servers" && name == gsName }, 3*time.Second) + kinds, found := streamEventKinds(t, user, ns, func(kind, name string) bool { return kind == "servers" && name == gsName }, 3*time.Second, changes) if !found { t.Fatalf("user stream never sent server %s; kinds=%v", gsName, kinds) } @@ -126,11 +147,41 @@ func TestAPI_EventStreamAndRoleEdits_FollowCallerPermissions(t *testing.T) { // streamEventKinds reads GET /events as cli and returns the set of event // kinds it received. When match reports true for a frame, it keeps // reading for grace (0 = stop at once); otherwise it stops after 30s. +// changes keeps both permitted and forbidden kinds active while connecting and +// reading; its worker is cancelled and joined before the helper returns. // found reports whether match was ever true. -func streamEventKinds(t *testing.T, cli *APIClient, ns string, match func(kind, name string) bool, grace time.Duration) (map[string]bool, bool) { +func streamEventKinds(t *testing.T, cli *APIClient, ns string, match func(kind, name string) bool, grace time.Duration, changes func(context.Context) error) (map[string]bool, bool) { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) defer cancel() + changesDone := make(chan struct{}) + var changesErr error + go func() { + defer close(changesDone) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + if err := changes(ctx); err != nil { + if ctx.Err() == nil { + changesErr = err + } + cancel() + return + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() + defer func() { + cancel() + <-changesDone + if changesErr != nil { + t.Errorf("event trigger failed: %v", changesErr) + } + }() req, err := http.NewRequestWithContext(ctx, http.MethodGet, cli.BaseURL+"/events?namespace="+ns, nil) if err != nil { t.Fatalf("new events request: %v", err) diff --git a/test/e2e/helm_install_e2e_test.go b/test/e2e/helm_install_e2e_test.go index ffa62e6aa..4c6ba7b7e 100644 --- a/test/e2e/helm_install_e2e_test.go +++ b/test/e2e/helm_install_e2e_test.go @@ -17,6 +17,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" "sigs.k8s.io/yaml" ) @@ -221,12 +222,58 @@ func TestHelmInstall_APIHealthz(t *testing.T) { // metricsProbeScript runs in a transient curl pod. It prints the public // API port's status code for /metrics, flags a Prometheus body there, and // reports whether the dedicated metrics port serves Prometheus text. -const metricsProbeScript = `code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://gameplane-api/metrics) +const metricsProbeScript = `code=$(curl -sS -o /tmp/public-metrics -w '%{http_code}' --max-time 5 http://gameplane-api/metrics) || exit 1 echo "public-status=$code" -if curl -s --max-time 5 http://gameplane-api/metrics | grep -q '^# HELP'; then echo "public-body=metrics"; fi -if curl -fsS --max-time 5 http://gameplane-api:9090/metrics | grep -q '^# HELP go_goroutines'; then echo "metrics-port=ok"; fi +if grep -q '^# HELP' /tmp/public-metrics; then echo "public-body=metrics"; fi +if curl -fsS -o /tmp/dedicated-metrics --max-time 5 http://gameplane-api:9090/metrics; then + if grep -q '^# HELP go_goroutines' /tmp/dedicated-metrics; then + echo "metrics-port=ok" + else + echo "metrics-port=unexpected-body" + head -n 10 /tmp/dedicated-metrics + fi +fi exit 0` +// Read completed Pod logs instead of attaching after its command has started. +// A fast probe can print its verdict before kubectl run establishes the stream. +func metricsProbeOutput(ctx context.Context, name string) (string, error) { + const ns = "gameplane-system" + noToken := false + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, AutomountServiceAccountToken: &noToken, + Containers: []corev1.Container{{Name: "probe", Image: "curlimages/curl:8.10.1", Command: []string{"sh", "-c", metricsProbeScript}}}, + }} + pods := envInstance.K8s.CoreV1().Pods(ns) + if _, err := pods.Create(ctx, pod, metav1.CreateOptions{}); err != nil { + return "", fmt.Errorf("create metrics probe: %w", err) + } + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + _ = pods.Delete(cleanupCtx, name, metav1.DeleteOptions{}) + }() + var phase corev1.PodPhase + if err := wait.PollUntilContextCancel(ctx, 250*time.Millisecond, true, func(ctx context.Context) (bool, error) { + current, err := pods.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + phase = current.Status.Phase + return phase == corev1.PodSucceeded || phase == corev1.PodFailed, nil + }); err != nil { + return "", fmt.Errorf("wait for metrics probe (phase %s): %w", phase, err) + } + out, err := pods.GetLogs(name, &corev1.PodLogOptions{Container: "probe"}).DoRaw(ctx) + if err != nil { + return "", fmt.Errorf("read metrics probe logs: %w", err) + } + if phase != corev1.PodSucceeded { + return string(out), fmt.Errorf("metrics probe pod finished in phase %s", phase) + } + return string(out), nil +} + // TestHelmInstall_MetricsNotOnPublicPort — the API's public port (the // Service port the Ingress and the web front end route to) does not serve // Prometheus metrics, and the dedicated metrics port that the ServiceMonitor @@ -240,15 +287,9 @@ func TestHelmInstall_MetricsNotOnPublicPort(t *testing.T) { // Random suffix so an Eventually retry doesn't collide with a // not-yet-cleaned-up pod from the previous tick. name := fmt.Sprintf("metrics-probe-%d", time.Now().UnixNano()) - o, err := envInstance.Kubectl( - t.Context(), - "run", "-n", "gameplane-system", - "--rm", "--restart=Never", "--attach", - "--image=curlimages/curl:8.10.1", - name, - "--command", "--", - "sh", "-c", metricsProbeScript, - ) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + o, err := metricsProbeOutput(ctx, name) if err != nil { return false, fmt.Sprintf("metrics probe pod failed: %v\n%s", err, o) } diff --git a/tunnel/Dockerfile.frp b/tunnel/Dockerfile.frp index a97ce8f8c..120e4e135 100644 --- a/tunnel/Dockerfile.frp +++ b/tunnel/Dockerfile.frp @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build +FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine AS build WORKDIR /src # tunnel's go.mod currently has no gameaction requirement (no local diff --git a/tunnel/Dockerfile.playit b/tunnel/Dockerfile.playit index 92cfb32ba..54c68af95 100644 --- a/tunnel/Dockerfile.playit +++ b/tunnel/Dockerfile.playit @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build +FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine AS build WORKDIR /src # tunnel's go.mod currently has no gameaction requirement (no local diff --git a/tunnel/Dockerfile.tailscale b/tunnel/Dockerfile.tailscale index fba707762..fc74b830b 100644 --- a/tunnel/Dockerfile.tailscale +++ b/tunnel/Dockerfile.tailscale @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build +FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine AS build WORKDIR /src # tunnel's go.mod currently has no gameaction requirement (no local @@ -24,16 +24,10 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ -ldflags="-s -w -X main.Version=${VERSION}" \ -o /out/tunnel . -# Copy tailscale binaries from the official upstream image pinned by digest. -# tailscale is distributed as official OCI images; pinning by digest -# ensures reproducible builds. The image ships both tailscaled and tailscale CLI. -FROM tailscale/tailscale:v1.104.2@sha256:b11583ad2e93e4671128538c88cf11f460444a5d7e449311dd1933a400ab849d AS tailscale-stage - FROM gcr.io/distroless/static:nonroot WORKDIR / COPY --from=build /out/tunnel /tunnel -COPY --from=tailscale-stage /usr/local/bin/tailscaled /usr/local/bin/tailscaled -COPY --from=tailscale-stage /usr/local/bin/tailscale /usr/local/bin/tailscale USER 65532:65532 EXPOSE 25565/tcp 25565/udp +EXPOSE 8091/tcp ENTRYPOINT ["/tunnel"] diff --git a/tunnel/go.mod b/tunnel/go.mod index da205c4d2..5978f4946 100644 --- a/tunnel/go.mod +++ b/tunnel/go.mod @@ -1,3 +1,51 @@ module github.com/GameplanePanel/gameplane/tunnel -go 1.26.0 +go 1.27.3 + +require tailscale.com v1.104.2 + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/creachadair/msync v0.10.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca // indirect + github.com/fxamacker/cbor/v2 v2.9.3 // indirect + github.com/gaissmai/bart v0.29.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.2 // indirect + github.com/klauspost/compress v1.20.1 // indirect + github.com/mdlayher/netlink v1.11.2 // indirect + github.com/mdlayher/socket v0.7.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/pires/go-proxyproto v0.15.0 // indirect + github.com/safchain/ethtool v0.7.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20260917222731-e0ed2d0d0fea // indirect + github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226 // indirect + github.com/x448/float16 v0.8.4 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v1.0.1 // indirect + gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72 // indirect +) diff --git a/tunnel/go.sum b/tunnel/go.sum new file mode 100644 index 000000000..82df36455 --- /dev/null +++ b/tunnel/go.sum @@ -0,0 +1,239 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aws/aws-sdk-go-v2 v1.46.0 h1:1kt7m/EKcEHt5mlyyxx9cSlMddRPIKbjb6DIQsu4HPk= +github.com/aws/aws-sdk-go-v2 v1.46.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/config v1.33.3 h1:h090b3O5S17bF87/0ysHZuIT/7DCb4EBRFQX2PMVPCw= +github.com/aws/aws-sdk-go-v2/config v1.33.3/go.mod h1:YYDB1kTejxbfAbEVUqgCtkVp26xvNCHev9cLKABMGAk= +github.com/aws/aws-sdk-go-v2/credentials v1.20.3 h1:tToOYM/LXev4NpfWlIYGDvBvjHmJ3HXpRU9ppl+pM6k= +github.com/aws/aws-sdk-go-v2/credentials v1.20.3/go.mod h1:wfGneWyncO7p67wqXV2IQhPk14JqIc25woKlaArT3WI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2 h1:Ldv7RPHs7qwwTscRjAl3YBud32f3BvdAGRmSvAx5L38= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2/go.mod h1:XyK6UV8xbo66ysVqLd2783C09pBYHOm8aKTRV5DVJ30= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.2 h1:q/PSLGuRWCChWg+dLnb9dWOnrCxJtnboXbBtFoqqRrI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.2/go.mod h1:TD1jvU2LvXkJexct5vBqcd8QlNXh5EmRUeL/Z32p0n4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2 h1:6fl86IPqKEXoySqiOWdfgbEp9OVbn44zTfEICNEBDhY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2/go.mod h1:63HDfhFkdzBpI8WGXTSKUHPKS6mqldj4u3LJW7RZtSU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2 h1:XMgIRS+uW9F3yFKnXGRrI9pkHi99CXTmoz2kz2/TGBA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2/go.mod h1:vorxDzK+n3jiv9a5ST/LG0Eu9cSv1CRdKTpG6pDMs+M= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.2 h1:ZtHYnumr6QyxhzEzNZwzQTFJEXOswrZqTTkRxthwvr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.2/go.mod h1:a1NXrYpBd311gBzn1UI5UzJyyvXktM4xNh/ydPiPpqY= +github.com/aws/aws-sdk-go-v2/service/signin v1.9.0 h1:c3k+k/CS4L+sAIH6fxikL+g5g2LpeNczaoyjjw1iMKI= +github.com/aws/aws-sdk-go-v2/service/signin v1.9.0/go.mod h1:AGIoQg99fBrOIQnF78TLx4lj18mc4gZ0hJx1UaLIFM4= +github.com/aws/aws-sdk-go-v2/service/ssm v1.77.0 h1:JqKSBJlS68F4nxoPLwD7tB3lJ3gWEK9p+7eOiBE/lsM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.77.0/go.mod h1:5TMMHmQGjCODMeIopSNfxkan11jG0SmFpyjcMvCLzHA= +github.com/aws/aws-sdk-go-v2/service/sso v1.37.0 h1:+rqBaOq7jzInjY8M12hr+zEe85JpRll9BjMx38r33Ok= +github.com/aws/aws-sdk-go-v2/service/sso v1.37.0/go.mod h1:XFlVwUsw3sYh8Hw37umYVJnrcWrwXWRxbNw/JY0bblw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.42.0 h1:hzM3GslEAOBcLn3DHH6ENToFi+vXP+n02W+x6zejAIM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.42.0/go.mod h1:588e7skMkYIYkSUseT8E3WKFfbAfrA1bj3Zf+qxJtJY= +github.com/aws/aws-sdk-go-v2/service/sts v1.49.0 h1:N7Ey8obY3uSui+cxl0OUzFlFmkxSucoJnrniFhw+cLc= +github.com/aws/aws-sdk-go-v2/service/sts v1.49.0/go.mod h1:zMBwjSf4Pt8a1OHYiZ5rPD0PJRK1kQrUaxhS/Dbld8E= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZoRJw= +github.com/axiomhq/hyperloglog v0.2.6/go.mod h1:YjX/dQqCR/7QYX0g8mu8UZAjpIenz1FKM71UEsjFoTo= +github.com/benbjohnson/immutable v0.4.3 h1:GYHcksoJ9K6HyAUpGxwZURrbTkXA0Dh4otXGqbhdrjA= +github.com/benbjohnson/immutable v0.4.3/go.mod h1:qJIKKSmdqz1tVzNtst1DZzvaqOU1onk1rc03IeM3Owk= +github.com/bradfitz/reco v0.0.0-20260929154613-b883fbd17e3f h1:jdXGSp5Ge5/kzikuxGOw9+6HSStUjkNBJZjGNtpItek= +github.com/bradfitz/reco v0.0.0-20260929154613-b883fbd17e3f/go.mod h1:oiCPXxBoDKSkb/RGEZwAZk8y/fwDlQ1vYlBbNRxPysw= +github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY= +github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc= +github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/mds v0.31.0 h1:h4SXXgallvZfD8CFQnQNAOtBkUGpZjPwNBRQpHpHM/g= +github.com/creachadair/mds v0.31.0/go.mod h1:IZ3oLffgVNZ4O5WXTPxx7EX5ilSl1ypdv5FekYyynyI= +github.com/creachadair/msync v0.10.1 h1:14jT5Ujob64Zp2D3ZcUidayDMoXikhmYN8xBxEuPKPk= +github.com/creachadair/msync v0.10.1/go.mod h1:dFf4Z0cxE2nibFmAdq5OqTuGn++GgEqA9JuKH+7ceSA= +github.com/creachadair/taskgroup v0.14.4 h1:ttR8StLWmYA1O6x96YlTpQLXjKyRpO4RBo+OcO6W6C0= +github.com/creachadair/taskgroup v0.14.4/go.mod h1:uhCtIEsa7zpeMAFixddhCYjbJi7e4JMyU7OXUygkSsg= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ= +github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c= +github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= +github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.29.0 h1:wO6HGE8g9YE0Wm0bCpYxwRzfQ4+fbJKOhL64e5ACGCI= +github.com/gaissmai/bart v0.29.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9 h1:J8S+Ms89nvHX1F9Ma2m3KhdTl41ZhbFKMyAJqvDA+iE= +github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= +github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/insomniacslk/dhcp v0.0.0-20260901064844-234b97448fae h1:nXGg65fXsylSUTNNWwvHuQsXev7mhIzQTnZREPTbWzs= +github.com/insomniacslk/dhcp v0.0.0-20260901064844-234b97448fae/go.mod h1:tGfUTcnFYGYvVNCaZZhwlJySU/fQQxh9TmpsFzWXnnY= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= +github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90= +github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM= +github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk= +github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o= +github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA= +github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= +github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.7.0 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ= +github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= +github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= +github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is= +github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ= +github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= +github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20260720160339-c86ca1284b96 h1:m9EAsCu/afUkg9YkknXaRqMPl/omkUNRol5yB7hbG0Y= +github.com/tailscale/golang-x-crypto v0.0.0-20260720160339-c86ca1284b96/go.mod h1:NC3xRCu4UR+m4n6ix8b6oLLbHa820Y0StbOQEdWTDo0= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20260917222731-e0ed2d0d0fea h1:ah5jt6ZnxMvNclt6kBBoLcMpLLjMA44PTsnov2y7gpU= +github.com/tailscale/web-client-prebuilt v0.0.0-20260917222731-e0ed2d0d0fea/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226 h1:v3Lpj2iHPWQDqeCwemQPz4fWweIEMLqBkwJqCjRyJQc= +github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226/go.mod h1:rUelGmuK4UnSJYM5gl5Mknp6YbwwcL8+VAPMhNYe+jg= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4= +github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8= +github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs= +go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f h1:+lI8cDJ4uceLipg2f1ODay7fEuLkk0BIHXd6PB8icxo= +golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8= +golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72 h1:EytKYr5WrVs+Aah/0xDO8ZAZZzS/iTEVC8ln4ipDbL0= +gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72/go.mod h1:8aLQqUBHDH8fY5y60lzmwDpMMbQCcT3EBfoSwhfaGCY= +honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w= +honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU= +software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.104.2 h1:zBbSW0tI4Xi2PhrZEl9yt6oWay3l8A+M8scsTnZpyAk= +tailscale.com v1.104.2/go.mod h1:D+gqEyJjFnwIaojBLaKxUbOfEO3cRXOaFaOZ5BIvYqs= diff --git a/tunnel/main.go b/tunnel/main.go index 64be7d11c..81879dedf 100644 --- a/tunnel/main.go +++ b/tunnel/main.go @@ -1,5 +1,5 @@ // Command tunnel is a relay client supervisor that runs as a pod and -// configures/starts/supervises a third-party relay process (frp, Tailscale, playit). +// configures/starts/supervises frp and playit, or embeds a Tailscale node. // The binary picks its behavior from the TUNNEL_TYPE env var and handles provider-specific // config rendering, credential management, and child process supervision. // @@ -21,7 +21,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "log" @@ -50,45 +49,8 @@ const ( // be compile-time string constants -- not variables -- for buildCommand // below to pass gosec's G204 subprocess-argument check structurally // (see the doc comment on buildCommand). - frpConfigPath = "/tmp/gameplane-tunnel-frpc.toml" - tailscaleConfigPath = "/tmp/gameplane-tunnel-tailscaled.json" - playitAuthPath = "/tmp/gameplane-tunnel-playit-auth" - - // tailscaleSocketPath is the control socket tailscaled listens on, - // set explicitly via --socket (both when starting tailscaled and when - // later talking to it with the `tailscale` CLI) because the non-root - // distroless image can't create tailscaled's Linux default - // (/run/tailscale/tailscaled.sock). This mirrors playitSocketPath's - // --socket-path relocation in playit_reporter.go for the same reason. - tailscaleSocketPath = "/tmp/gameplane-tunnel-tailscaled.sock" - - // tailscaleAuthKeyPath holds the Tailscale auth key, mode 0600, when - // tags are requested. The key then stays out of tailscaled's config so - // that registration can happen through `tailscale up - // --auth-key=file: --advertise-tags=...` instead (see - // renderTailscaleConfig and registerTailscaleOnce). Passing a file keeps - // the key out of argv. It lives in /tmp next to the other rendered - // files and is removed when run returns. - tailscaleAuthKeyPath = "/tmp/gameplane-tunnel-tailscale-authkey" -) - -// Timing for registering a tagged Tailscale device (registerTailscaleOnce). -const ( - // tailscaleReadyTimeout bounds how long waitForTailscaled polls for - // tailscaled's socket to answer with a settled BackendState before - // giving up on registering the device for this supervisor run. It is - // generous because tailscaled may be restarting under the backoff loop - // in run, but bounded so a daemon that never comes up doesn't leave a - // goroutine polling forever. - tailscaleReadyTimeout = 2 * time.Minute - - // tailscaleReadyPollInterval is how often waitForTailscaled polls - // `tailscale status --json`. - tailscaleReadyPollInterval = 2 * time.Second - - // tailscaleUpTimeout bounds a single `tailscale up`, which includes a - // round trip to Tailscale's control plane. - tailscaleUpTimeout = 2 * time.Minute + frpConfigPath = "/tmp/gameplane-tunnel-frpc.toml" + playitAuthPath = "/tmp/gameplane-tunnel-playit-auth" ) // credentialsDir is the directory holding the mounted Secret's credential @@ -146,7 +108,7 @@ type Config struct { // Tailscale-specific TailscaleHostname string TailscaleTags string // comma-separated - BackingServicePorts string // "name:port,..." format + BackingServicePorts string // Tailscale: name:port:protocol; playit: name:port // Playit-specific PlayitTunnelName string @@ -237,11 +199,8 @@ func run(ctx context.Context, cfg Config) error { return fmt.Errorf("read credentials: %w", err) } - // tailscale with tags writes the auth key to its own file (see - // renderTailscaleConfig); remove it on the way out, including when - // rendering the config itself fails after the key was written. if cfg.TunnelType == "tailscale" { - defer func() { _ = os.Remove(tailscaleAuthKeyPath) }() + return runTailscale(ctx, cfg, creds) } // Render the provider's config file. @@ -267,25 +226,6 @@ func run(ctx context.Context, cfg Config) error { }() } - // tailscale with tags: renderTailscaleConfig left the auth key out of - // tailscaled's config, so register the device via `tailscale up` over - // tailscaled's socket (see registerTailscaleOnce). This keeps retrying - // until tailscaled settles and registration succeeds, or until ctx is - // cancelled: the login and the granted tags persist in tailscaled's - // local state file (/tmp/tailscale.state, --state in buildCommand) - // across a tailscaled restart that reuses it. Without tags, tailscaled - // logs in from its config by itself and nothing runs here. - if cfg.TunnelType == "tailscale" { - if tags, err := parseTailscaleTags(cfg.TailscaleTags); err == nil && len(tags) > 0 { - regCtx, stopRegistrar := context.WithCancel(ctx) - waitRegistrar := startTailscaleRegistrar(regCtx, cfg.TailscaleHostname, tags) - defer func() { - stopRegistrar() - waitRegistrar() - }() - } - } - // Supervise the relay process with exponential backoff on exit. backoff := &exponentialBackoff{} for { @@ -357,8 +297,6 @@ func renderConfig(cfg Config, credential string) (string, error) { switch cfg.TunnelType { case "frp": return renderFrpConfig(cfg, credential) - case "tailscale": - return renderTailscaleConfig(cfg.TailscaleHostname, credential, cfg.TailscaleTags) case "playit": return renderPlayitConfig(credential) default: @@ -423,110 +361,6 @@ remotePort = %s return frpConfigPath, nil } -// tailscaledConfig mirrors (the subset we use of) tailscaled's own -// ConfigVAlpha struct (ipn/conf.go in tailscale/tailscale), consumed via -// `tailscaled --config=`. This is tailscaled's documented declarative -// config file (https://tailscale.com/docs/reference/tailscaled/tailescaled-config-file); -// "alpha0" is the only version value it currently accepts, and the docs -// note the schema may still change. It is what lets a bare `tailscaled` -// process (no containerboot, no separate `tailscale up`) authenticate and -// set its hostname in a single exec: containerboot's TS_AUTHKEY env var and -// `tailscale up --hostname=` are containerboot/CLI conveniences that build -// on top of this same mechanism, but neither is read by tailscaled itself, -// which is why the previous TS_AUTHKEY env var and --hostname flag here -// were both no-ops (the latter would have made tailscaled reject the flag -// and exit immediately). -type tailscaledConfig struct { - Version string `json:"version"` - AuthKey string `json:"authKey,omitempty"` - Hostname string `json:"hostname,omitempty"` -} - -// renderTailscaleConfig generates tailscaled's declarative config file (see -// tailscaledConfig for the shape) at the fixed tailscaleConfigPath (see the -// const block near the top of the file for why the path is fixed rather -// than a random os.CreateTemp name). -// -// It never writes a "tags" key: tailscaled's alpha0 declarative config -// (ipn.ConfigVAlpha, confirmed against tailscale/tailscale v1.102.4's -// ipn/conf.go) has no field for ACL tags, and its loader (ipn/conffile) -// decodes with encoding/json's DisallowUnknownFields, so a "tags" key made -// tailscaled refuse the config and the tunnel never came up. -// -// ACL tags can only be requested when the device registers (`tailscale up -// --advertise-tags`; `tailscale set` has no such flag, and changing tags on -// a running node needs a fresh login), so the file's content depends on -// whether tagsStr yields any valid tag (see parseTailscaleTags): -// -// - No tags (tagsStr empty, blank, or invalid): the auth key and hostname -// go into the config and tailscaled logs in by itself, exactly as it -// did before tags were supported. Nothing else runs. -// - Tags: the config carries only the hostname and "locked": false, and -// the auth key goes to its own 0600 file at tailscaleAuthKeyPath. -// Without an auth key in the config, tailscaled starts in NeedsLogin -// and waits; startTailscaleRegistrar then registers it once over the -// control socket with `tailscale up --auth-key=file: -// --advertise-tags=...`. "locked": false is required for that: -// ipn.ConfigVAlpha's Locked defaults to true, and a locked config makes -// tailscaled reject every CLI prefs change ("config file is locked"). -// -// The JSON is built from a map rather than by marshaling the tailscaledConfig -// struct directly: gosec's G117 rule flags any exported struct field whose -// name or JSON tag matches a secret-like pattern (AuthKey / "authKey" both -// do) when it's passed straight to json.Marshal. That's a real signal in -// general, but here marshaling the key is the entire point -- tailscaled -// reads its auth key from exactly this file (see the type's doc comment) -- -// so there's nothing to fix behaviorally. G117 only inspects struct field -// tags/names, not map keys, so building the object as a map sidesteps the -// false positive structurally instead of suppressing it. tailscaledConfig -// itself is kept as the documented shape and is what tests decode the -// written file back into (see TestRenderTailscaleConfig). -func renderTailscaleConfig(hostname, authKey, tagsStr string) (string, error) { - tags, tagsErr := parseTailscaleTags(tagsStr) - renderTailscaleConfigLogTags(tagsStr, tags, tagsErr) - - fields := map[string]any{"version": "alpha0"} - if hostname != "" { - fields["hostname"] = hostname - } - if len(tags) > 0 { - fields["locked"] = false - if err := os.WriteFile(tailscaleAuthKeyPath, []byte(authKey), 0o600); err != nil { - return "", fmt.Errorf("write tailscale auth key file: %w", err) - } - } else if authKey != "" { - fields["authKey"] = authKey - } - - data, err := json.Marshal(fields) - if err != nil { - return "", fmt.Errorf("marshal tailscaled config: %w", err) - } - - if err := os.WriteFile(tailscaleConfigPath, data, 0o600); err != nil { - return "", fmt.Errorf("write tailscaled config: %w", err) - } - - return tailscaleConfigPath, nil -} - -// renderTailscaleConfigLogTags logs what renderTailscaleConfig does with a -// non-empty TAILSCALE_TAGS: either that the tags will be requested when the -// device registers (and what the tailnet ACL has to allow for that), or, -// when parseTailscaleTags rejected them, that they are ignored and the -// device registers untagged. Split out from renderTailscaleConfig so a test -// can assert on the message via log.SetOutput. -func renderTailscaleConfigLogTags(tagsStr string, tags []string, tagsErr error) { - switch { - case tagsStr == "": - return - case tagsErr != nil: - log.Printf("tailscale tunnel: ignoring TAILSCALE_TAGS=%q: %v; the device registers untagged", tagsStr, tagsErr) - case len(tags) > 0: - log.Printf("tailscale tunnel: TAILSCALE_TAGS=%q is set; tags %s will be requested when the device registers via `tailscale up --advertise-tags` (the tailnet ACL must grant tagOwners for them to the auth key's owner)", tagsStr, strings.Join(tags, ",")) - } -} - // tailscaleTagNameChar reports whether b may appear in a tag name after the // "tag:" prefix: ASCII letters, digits and '-', per tailcfg.CheckTag. func tailscaleTagNameChar(b byte) bool { @@ -542,8 +376,8 @@ func tailscaleTagNameChar(b byte) bool { // Every resulting tag must pass the same rule as tailscale's // tailcfg.CheckTag: "tag:" followed by an ASCII letter, then only ASCII // letters, digits or '-'. The first tag that fails is returned as an error -// and no tags are returned, so a bad value can never reach the -// `tailscale up` argv (see tailscaleUpArgs). It returns (nil, nil) when no +// and no tags are returned, so a bad value can never reach tsnet registration. +// It returns (nil, nil) when no // non-empty entry remains. func parseTailscaleTags(tagsStr string) ([]string, error) { var tags []string @@ -577,274 +411,6 @@ func parseTailscaleTags(tagsStr string) ([]string, error) { return tags, nil } -// tailscaleUpArgs builds the argv (excluding the binary itself) for the -// one-off `tailscale up` that registers the device when tags are requested: -// -// --socket= up --reset --auth-key=file: --hostname= [--advertise-tags=] -// -// Flag names and forms are confirmed against tailscale v1.102.4 (the -// version Dockerfile.tailscale ships): --socket is a root flag -// (cmd/tailscale/cli/cli.go); --auth-key reads the key from a file when the -// value starts with "file:" (up.go resolveValueFromFile), so the key never -// appears in argv or /proc//cmdline; --advertise-tags takes a -// comma-separated list. --reset resets every setting not named on the -// command line to its default. Without it, `tailscale up` on a node with -// saved prefs (a reused state file) refuses to run unless every non-default -// setting is repeated on the command line (checkForAccidentalSettingReverts). -// This supervisor sets nothing beyond the hostname, so the defaults are what -// it wants anyway. With no tags (the untagged fallback) --advertise-tags is -// left out, which with --reset clears any previously requested tags. -// -// Every value is a single "--flag=value" argv element, so no value can be -// read as a separate flag, and nothing goes through a shell. tags must come -// from parseTailscaleTags, which has already validated each one. -func tailscaleUpArgs(hostname string, tags []string) []string { - args := []string{ - "--socket=" + tailscaleSocketPath, - "up", - "--reset", - "--auth-key=file:" + tailscaleAuthKeyPath, - "--hostname=" + hostname, - } - if len(tags) > 0 { - args = append(args, "--advertise-tags="+strings.Join(tags, ",")) - } - return args -} - -// runTailscaleCLI runs the `tailscale` CLI (not tailscaled) against args -// and returns its stdout. It is a package-level variable, rather than a -// plain function, solely so tests can substitute a fake in place of -// exec'ing the real binary (see credentialsDir above for the same -// pattern). Production code never reassigns it; only tests do. -var runTailscaleCLI = execTailscaleCLI - -// execTailscaleCLI is runTailscaleCLI's real implementation. It execs the -// fixed /usr/local/bin/tailscale binary (shipped in the tailscale provider -// image alongside tailscaled; see Dockerfile.tailscale) with args, tied to -// ctx so supervisor shutdown also stops it. The binary path is a literal -// and args is a plain argv slice built only by this file -// (tailscaleStatusArgs and tailscaleUpArgs), never passed through a shell. -// The only non-constant values in it are the hostname, as a single -// "--hostname=" element, and tags already validated by parseTailscaleTags. -// The auth key is never in args; `tailscale up` reads it from a file. -// -// The argv is appended to cmd.Args after exec.CommandContext rather than -// passed to it, so gosec's G204 check sees a call with only literal -// arguments. The reasoning above is the actual justification. -func execTailscaleCLI(ctx context.Context, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, "/usr/local/bin/tailscale") - cmd.Args = append(cmd.Args, args...) - out, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { - return out, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(exitErr.Stderr))) - } - return out, err - } - return out, nil -} - -// tailscaleStatusArgs builds the argv for `tailscale status --json` over -// tailscaleSocketPath. --peers=false keeps the output small: only BackendState and Self -// are read. -func tailscaleStatusArgs() []string { - return []string{"--socket=" + tailscaleSocketPath, "status", "--json", "--peers=false"} -} - -// tailscaleStatus is the subset of `tailscale status --json`'s output this -// supervisor reads, confirmed against tailscale v1.102.4's -// ipn/ipnstate.Status: BackendState is an ipn.State string, and -// Self.Tags lists the ACL tags the control plane has granted to this node, -// fully qualified ("tag:x"). -type tailscaleStatus struct { - BackendState string `json:"BackendState"` - Self *struct { - Tags []string `json:"Tags"` - } `json:"Self"` -} - -// hasExactTags reports whether the node is Running and its granted tags -// are exactly want, in any order, as a set. Both granted and requested -// tags are deduplicated for comparison. -func (st tailscaleStatus) hasExactTags(want []string) bool { - if st.BackendState != "Running" || st.Self == nil { - return false - } - have := make(map[string]bool) - for _, tag := range st.Self.Tags { - have[tag] = true - } - wantSet := make(map[string]bool) - for _, tag := range want { - wantSet[tag] = true - } - if len(have) != len(wantSet) { - return false - } - for tag := range wantSet { - if !have[tag] { - return false - } - } - return true -} - -// tailscaleSettledStates are the BackendState values at which tailscaled -// has finished starting up and `tailscale up` can act on it. The others are -// transient: "NoState" before the config and state file are loaded, and -// "Starting" while an existing login is connecting. Running `up` during -// "Starting" would make a node that already has the right tags log in -// again for nothing. -var tailscaleSettledStates = map[string]bool{ - "NeedsLogin": true, - "NeedsMachineAuth": true, - "Stopped": true, - "Running": true, -} - -// queryTailscaleStatus runs `tailscale status --json` once over -// tailscaleSocketPath and decodes it. -func queryTailscaleStatus(ctx context.Context) (tailscaleStatus, error) { - var st tailscaleStatus - out, err := runTailscaleCLI(ctx, tailscaleStatusArgs()...) - if err != nil { - return st, err - } - if err := json.Unmarshal(out, &st); err != nil { - return st, fmt.Errorf("parse tailscale status: %w", err) - } - return st, nil -} - -// waitForTailscaled polls `tailscale status --json` over -// tailscaleSocketPath every tailscaleReadyPollInterval until tailscaled's -// socket answers with a settled BackendState (see tailscaleSettledStates), -// ctx is cancelled, or tailscaleReadyTimeout elapses. It returns the -// settled status, or the last error seen. -func waitForTailscaled(ctx context.Context) (tailscaleStatus, error) { - deadline := time.Now().Add(tailscaleReadyTimeout) - var lastErr error - for { - st, err := queryTailscaleStatus(ctx) - switch { - case err != nil: - lastErr = err - case tailscaleSettledStates[st.BackendState]: - return st, nil - default: - lastErr = fmt.Errorf("tailscaled not ready yet (BackendState=%q)", st.BackendState) - } - - if ctx.Err() != nil { - return tailscaleStatus{}, ctx.Err() - } - if time.Now().After(deadline) { - return tailscaleStatus{}, fmt.Errorf("timed out after %v waiting for tailscaled: %w", tailscaleReadyTimeout, lastErr) - } - select { - case <-ctx.Done(): - return tailscaleStatus{}, ctx.Err() - case <-time.After(tailscaleReadyPollInterval): - } - } -} - -// runTailscaleUp runs one bounded `tailscale up` with the argv from -// tailscaleUpArgs. -func runTailscaleUp(ctx context.Context, hostname string, tags []string) error { - upCtx, cancel := context.WithTimeout(ctx, tailscaleUpTimeout) - defer cancel() - _, err := runTailscaleCLI(upCtx, tailscaleUpArgs(hostname, tags)...) - return err -} - -// registerTailscaleOnce registers the device with the tailnet, requesting -// tags, for a tagged tunnel. In that case renderTailscaleConfig left the -// auth key out of tailscaled's config, so tailscaled waits in NeedsLogin -// until this runs. -// -// 1. Wait for tailscaled's socket to answer with a settled state -// (waitForTailscaled), retrying on timeout. -// 2. If the node is already Running with exactly these tags (a state file -// reused across a restart), do nothing. -// 3. Otherwise run `tailscale up ... --advertise-tags=` once. -// 4. If that fails, most often because the tailnet ACL doesn't grant -// tagOwners for the tags to the auth key's owner, log it. If the node -// is not Running afterwards, run `tailscale up` once more without -// --advertise-tags, so the tunnel still comes up, untagged. -// -// Every failure is logged and none of them stops the supervisor or the -// relay process, so an ACL problem only the tailnet admin can fix never -// turns into a crash loop. -func registerTailscaleOnce(ctx context.Context, hostname string, tags []string) { - var st tailscaleStatus - var err error - for { - st, err = waitForTailscaled(ctx) - if err == nil { - break - } - if ctx.Err() != nil { - return - } - log.Printf("tailscale tunnel: still waiting for tailscaled to settle: %v", err) - // Retry after a brief delay without spinning - select { - case <-time.After(5 * time.Second): - continue - case <-ctx.Done(): - return - } - } - - joined := strings.Join(tags, ",") - if st.hasExactTags(tags) { - log.Printf("tailscale tunnel: device is already running with tags %s; skipping `tailscale up`", joined) - return - } - - err = runTailscaleUp(ctx, hostname, tags) - if err == nil { - log.Printf("tailscale tunnel: registered the device with tags %s", joined) - return - } - if ctx.Err() != nil { - return - } - log.Printf("tailscale tunnel: `tailscale up --advertise-tags=%s` failed: %v; the tailnet ACL must grant tagOwners for these tags to the auth key's owner.", joined, err) - - if st, err := queryTailscaleStatus(ctx); err == nil && st.BackendState == "Running" { - if st.Self != nil && len(st.Self.Tags) > 0 { - oldTags := strings.Join(st.Self.Tags, ",") - log.Printf("tailscale tunnel: device is still running with previous tags %s; they remain granted until the tailnet ACL is fixed or they are removed in the tailnet admin console", oldTags) - return - } - log.Printf("tailscale tunnel: device is still running without tags") - return - } - if err := runTailscaleUp(ctx, hostname, nil); err != nil { - if ctx.Err() == nil { - log.Printf("tailscale tunnel: untagged `tailscale up` also failed: %v", err) - } - return - } - log.Printf("tailscale tunnel: registered the device without tags") -} - -// startTailscaleRegistrar launches registerTailscaleOnce in a goroutine -// and returns a function that waits for it to finish, mirroring -// startPlayitReporter's shape in playit_reporter.go. -func startTailscaleRegistrar(ctx context.Context, hostname string, tags []string) (wait func()) { - done := make(chan struct{}) - go func() { - defer close(done) - registerTailscaleOnce(ctx, hostname, tags) - }() - return func() { <-done } -} - // renderPlayitConfig writes the secret key to the fixed playitAuthPath // (see the const block near the top of the file for why the path is fixed // rather than a random os.CreateTemp name) for playitd's --secret-path flag. @@ -886,12 +452,9 @@ func escapeTomlString(s string) string { // is ever reached in the normal main() contract, so the nil case only // happens if a caller builds a Config by hand (see TestBuildCommandUnknownType). // -// All three providers take their secret via the rendered config file -// (see renderConfig and the frpConfigPath/tailscaleConfigPath/ -// playitAuthPath consts) rather than a raw credential argument here -- -// frpc's TOML embeds the token, tailscaled's declarative config JSON embeds -// the auth key and hostname, and playitd's --secret-path points straight at -// the secret file -- so buildCommand itself never needs the raw credential. +// frpc's TOML embeds its token, and playitd's --secret-path points to a +// private credential file. buildCommand never needs the raw credential. +// Tailscale uses the embedded node path and does not call this function. // // The relay binary path and config-file path are both named constants // (rather than parameters or computed variables) so every argument to @@ -917,24 +480,6 @@ func buildCommand(ctx context.Context, cfg Config) *exec.Cmd { // Confirmed against fatedier/frp's cmd/frpc/sub/root.go: "-c"/"--config" // is frpc's real config-file flag. return exec.CommandContext(ctx, "/usr/local/bin/frpc", "-c", frpConfigPath) - case "tailscale": - // Tailscale uses tailscaled (daemon) with flags rather than a config file. - // The pod's hardened securityContext (no NET_ADMIN, no /dev/net/tun device) - // requires userspace networking mode via --tun=userspace-networking flag. - // --config points at the declarative config file (see tailscaledConfig) - // carrying the hostname and, unless tags are requested, the auth key. - // --socket relocates the control socket to a path this non-root image - // can create (see tailscaleSocketPath's doc comment); with tags, - // registerTailscaleOnce registers the device over this same socket - // via the `tailscale` CLI. All flags - // confirmed against tailscale/tailscale's cmd/tailscaled/tailscaled.go - // flag definitions. - return exec.CommandContext(ctx, "/usr/local/bin/tailscaled", - "--tun=userspace-networking", - "--state=/tmp/tailscale.state", - "--config="+tailscaleConfigPath, - "--socket="+tailscaleSocketPath, - ) case "playit": // Confirmed against playit-cloud/playit-agent's packages/playitd/src/bin/playitd.rs // and its own Dockerfile/docker/entrypoint.sh, which exec exactly this diff --git a/tunnel/main_test.go b/tunnel/main_test.go index ce0881de1..05f4784df 100644 --- a/tunnel/main_test.go +++ b/tunnel/main_test.go @@ -1,17 +1,12 @@ package main import ( - "bytes" "context" - "encoding/json" "errors" - "log" "os" "os/exec" "path/filepath" "strings" - "sync" - "sync/atomic" "testing" "time" ) @@ -76,7 +71,7 @@ func TestLoadConfigTailscale(t *testing.T) { "TAILSCALE_HOSTNAME": "my-game", "TAILSCALE_TAGS": "tag:gameplane,tag:game", "BACKING_SERVICE_DNS": "my-server.games.svc", - "BACKING_SERVICE_PORTS": "game:25565", + "BACKING_SERVICE_PORTS": "game:25565:tcp", })) if err != nil { t.Fatalf("loadConfig() error = %v", err) @@ -419,248 +414,6 @@ func TestRenderFrpConfigInvalidProtocol(t *testing.T) { } } -func TestRenderTailscaleConfig(t *testing.T) { - path, err := renderTailscaleConfig("my-game", "test-auth-key", "") - if err != nil { - t.Fatalf("renderTailscaleConfig() error = %v", err) - } - defer os.Remove(path) - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read config file: %v", err) - } - - var got tailscaledConfig - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("tailscaled config file is not valid JSON: %v (content: %s)", err, data) - } - if got.Version != "alpha0" { - t.Errorf("Version = %q, want %q", got.Version, "alpha0") - } - if got.AuthKey != "test-auth-key" { - t.Errorf("AuthKey = %q, want %q", got.AuthKey, "test-auth-key") - } - if got.Hostname != "my-game" { - t.Errorf("Hostname = %q, want %q", got.Hostname, "my-game") - } -} - -func TestRenderTailscaleConfigNoHostname(t *testing.T) { - path, err := renderTailscaleConfig("", "test-auth-key", "") - if err != nil { - t.Fatalf("renderTailscaleConfig() error = %v", err) - } - defer os.Remove(path) - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read config file: %v", err) - } - - var got tailscaledConfig - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("tailscaled config file is not valid JSON: %v", err) - } - if got.Hostname != "" { - t.Errorf("Hostname = %q, want empty", got.Hostname) - } - if got.AuthKey != "test-auth-key" { - t.Errorf("AuthKey = %q, want %q", got.AuthKey, "test-auth-key") - } -} - -// removeTailscaleRenderedFiles deletes the files renderTailscaleConfig can -// write, before and after a test, so one test's auth key file can't make -// another test's "not written" assertion pass or fail by accident. -func removeTailscaleRenderedFiles(t *testing.T) { - t.Helper() - _ = os.Remove(tailscaleConfigPath) - _ = os.Remove(tailscaleAuthKeyPath) - t.Cleanup(func() { - _ = os.Remove(tailscaleConfigPath) - _ = os.Remove(tailscaleAuthKeyPath) - }) -} - -// readTailscaleConfigMap decodes the rendered tailscaled config into a -// generic map, so a test can check for keys tailscaledConfig doesn't -// declare ("tags", "locked"). -func readTailscaleConfigMap(t *testing.T, path string) map[string]any { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read config file: %v", err) - } - var got map[string]any - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("tailscaled config is not valid JSON: %v (content: %s)", err, data) - } - return got -} - -// TestRenderTailscaleConfigWithTagsOmitsTags asserts what renderTailscaleConfig -// writes when TAILSCALE_TAGS holds valid tags: -// - no "tags" key: tailscaled's alpha0 declarative config (ipn.ConfigVAlpha) -// has no field for ACL tags, and its loader (ipn/conffile) decodes with -// DisallowUnknownFields, so the key would make tailscaled refuse to start; -// - no "authKey" key, so tailscaled waits in NeedsLogin for -// registerTailscaleOnce's `tailscale up --advertise-tags` instead of -// logging in untagged by itself; -// - "locked": false, without which tailscaled rejects that `tailscale up` -// ("config file is locked"); -// - the auth key in tailscaleAuthKeyPath, mode 0600, for -// `tailscale up --auth-key=file:`. -// -// Decoding into a generic map (rather than tailscaledConfig) is what lets -// this test catch a "tags" key reappearing in the JSON. -func TestRenderTailscaleConfigWithTagsOmitsTags(t *testing.T) { - removeTailscaleRenderedFiles(t) - - path, err := renderTailscaleConfig("my-game", "test-auth-key", "tag:gameplane,tag:game") - if err != nil { - t.Fatalf("renderTailscaleConfig() error = %v", err) - } - - got := readTailscaleConfigMap(t, path) - if _, ok := got["tags"]; ok { - t.Errorf("rendered tailscaled config has a %q key = %v; tailscaled's alpha0 config has no such field and DisallowUnknownFields would make tailscaled refuse to start", "tags", got["tags"]) - } - if _, ok := got["authKey"]; ok { - t.Errorf("rendered tailscaled config has an authKey with tags set; tailscaled would log in untagged before `tailscale up --advertise-tags` runs") - } - if locked, ok := got["locked"]; !ok || locked != false { - t.Errorf("locked = %v (present %v), want false; a locked config makes tailscaled reject `tailscale up`", locked, ok) - } - if got["hostname"] != "my-game" { - t.Errorf("hostname = %v, want %q", got["hostname"], "my-game") - } - if got["version"] != "alpha0" { - t.Errorf("version = %v, want %q", got["version"], "alpha0") - } - - key, err := os.ReadFile(tailscaleAuthKeyPath) - if err != nil { - t.Fatalf("read auth key file: %v", err) - } - if string(key) != "test-auth-key" { - t.Errorf("auth key file = %q, want %q", key, "test-auth-key") - } - info, err := os.Stat(tailscaleAuthKeyPath) - if err != nil { - t.Fatalf("stat auth key file: %v", err) - } - if perm := info.Mode().Perm(); perm != 0o600 { - t.Errorf("auth key file mode = %o, want 600", perm) - } -} - -// TestRenderTailscaleConfigNoTags asserts that without tags the config is -// what it was before tags were supported: auth key inside the config, no -// "tags" or "locked" key, and no separate auth key file. -func TestRenderTailscaleConfigNoTags(t *testing.T) { - removeTailscaleRenderedFiles(t) - - path, err := renderTailscaleConfig("my-game", "test-auth-key", "") - if err != nil { - t.Fatalf("renderTailscaleConfig() error = %v", err) - } - - got := readTailscaleConfigMap(t, path) - if _, ok := got["tags"]; ok { - t.Errorf("rendered tailscaled config has a %q key = %v, want none", "tags", got["tags"]) - } - if _, ok := got["locked"]; ok { - t.Errorf("rendered tailscaled config has a %q key = %v, want none without tags", "locked", got["locked"]) - } - if got["authKey"] != "test-auth-key" { - t.Errorf("authKey = %v, want %q", got["authKey"], "test-auth-key") - } - if _, err := os.Stat(tailscaleAuthKeyPath); !os.IsNotExist(err) { - t.Errorf("auth key file %s exists (stat err = %v), want it not written without tags", tailscaleAuthKeyPath, err) - } -} - -// TestRenderTailscaleConfigInvalidTagsRegistersUntagged asserts that tags -// parseTailscaleTags rejects are ignored with a log line, and the config -// falls back to the untagged shape (auth key in the config, no key file), -// so a bad tag can't keep the tunnel from coming up. -func TestRenderTailscaleConfigInvalidTagsRegistersUntagged(t *testing.T) { - removeTailscaleRenderedFiles(t) - - var buf bytes.Buffer - orig := log.Writer() - log.SetOutput(&buf) - defer log.SetOutput(orig) - - path, err := renderTailscaleConfig("my-game", "test-auth-key", "tag:ok,tag:not ok") - if err != nil { - t.Fatalf("renderTailscaleConfig() error = %v", err) - } - - got := readTailscaleConfigMap(t, path) - if got["authKey"] != "test-auth-key" { - t.Errorf("authKey = %v, want %q", got["authKey"], "test-auth-key") - } - if _, ok := got["locked"]; ok { - t.Errorf("rendered tailscaled config has a %q key = %v, want none for rejected tags", "locked", got["locked"]) - } - if _, err := os.Stat(tailscaleAuthKeyPath); !os.IsNotExist(err) { - t.Errorf("auth key file %s exists (stat err = %v), want it not written for rejected tags", tailscaleAuthKeyPath, err) - } - if out := buf.String(); !strings.Contains(out, `ignoring TAILSCALE_TAGS="tag:ok,tag:not ok"`) { - t.Errorf("log output = %q, want it to say the tags are ignored", out) - } -} - -// TestRenderTailscaleConfigLogTagsNotesRegistration asserts that -// renderTailscaleConfig logs an informational line when TAILSCALE_TAGS is -// set: the raw value, and that the tags are requested at registration via -// `tailscale up --advertise-tags`, which needs a tagOwners grant. -func TestRenderTailscaleConfigLogTagsNotesRegistration(t *testing.T) { - removeTailscaleRenderedFiles(t) - - var buf bytes.Buffer - orig := log.Writer() - log.SetOutput(&buf) - defer log.SetOutput(orig) - - if _, err := renderTailscaleConfig("my-game", "test-auth-key", "tag:gameplane,tag:game"); err != nil { - t.Fatalf("renderTailscaleConfig() error = %v", err) - } - - got := buf.String() - if !strings.Contains(got, `TAILSCALE_TAGS="tag:gameplane,tag:game"`) { - t.Errorf("log output = %q, want it to mention the requested tags", got) - } - if !strings.Contains(got, "tailscale up --advertise-tags") || !strings.Contains(got, "tagOwners") { - t.Errorf("log output = %q, want it to name `tailscale up --advertise-tags` and the tagOwners requirement", got) - } -} - -// TestRenderTailscaleConfigLogTagsSilentWhenUnset asserts nothing is -// logged when TAILSCALE_TAGS is empty. -func TestRenderTailscaleConfigLogTagsSilentWhenUnset(t *testing.T) { - removeTailscaleRenderedFiles(t) - - var buf bytes.Buffer - orig := log.Writer() - log.SetOutput(&buf) - defer log.SetOutput(orig) - - if _, err := renderTailscaleConfig("my-game", "test-auth-key", ""); err != nil { - t.Fatalf("renderTailscaleConfig() error = %v", err) - } - - if got := buf.String(); got != "" { - t.Errorf("log output = %q, want empty", got) - } -} - -// ----------------------------------------------------------------------- -// parseTailscaleTags / tailscaleUpArgs / registerTailscaleOnce tests -// ----------------------------------------------------------------------- - func TestParseTailscaleTags(t *testing.T) { tests := []struct { name string @@ -705,329 +458,6 @@ func TestParseTailscaleTagsDeduplicate(t *testing.T) { } } -// TestHasExactTagsRejectsDuplicateWant asserts that duplicate requested tags -// do not fool the exact-set check when the granted set differs. -func TestHasExactTagsRejectsDuplicateWant(t *testing.T) { - st := tailscaleStatus{ - BackendState: "Running", - Self: &struct { - Tags []string `json:"Tags"` - }{ - Tags: []string{"tag:a", "tag:b"}, - }, - } - if st.hasExactTags([]string{"tag:a", "tag:a"}) { - t.Error("hasExactTags([tag:a tag:a]) = true, want false when granted [tag:a tag:b]") - } -} - -func TestTailscaleUpArgs(t *testing.T) { - tests := []struct { - name string - hostname string - tags []string - want []string - }{ - { - name: "with tags", - hostname: "my-game", - tags: []string{"tag:a", "tag:b"}, - want: []string{ - "--socket=/tmp/gameplane-tunnel-tailscaled.sock", - "up", - "--reset", - "--auth-key=file:/tmp/gameplane-tunnel-tailscale-authkey", - "--hostname=my-game", - "--advertise-tags=tag:a,tag:b", - }, - }, - { - name: "untagged fallback omits --advertise-tags", - hostname: "my-game", - tags: nil, - want: []string{ - "--socket=/tmp/gameplane-tunnel-tailscaled.sock", - "up", - "--reset", - "--auth-key=file:/tmp/gameplane-tunnel-tailscale-authkey", - "--hostname=my-game", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tailscaleUpArgs(tt.hostname, tt.tags) - if !equalArgs(got, tt.want) { - t.Errorf("tailscaleUpArgs() = %v, want %v", got, tt.want) - } - for _, a := range got { - if strings.Contains(a, "test-auth-key") { - t.Errorf("argv %v contains the auth key", got) - } - } - }) - } -} - -func TestTailscaleStatusArgs(t *testing.T) { - want := []string{"--socket=/tmp/gameplane-tunnel-tailscaled.sock", "status", "--json", "--peers=false"} - if got := tailscaleStatusArgs(); !equalArgs(got, want) { - t.Errorf("tailscaleStatusArgs() = %v, want %v", got, want) - } -} - -// stubTailscaleCLI replaces runTailscaleCLI with fn for the duration of the -// test, restoring the real implementation afterward. -func stubTailscaleCLI(t *testing.T, fn func(ctx context.Context, args ...string) ([]byte, error)) { - t.Helper() - orig := runTailscaleCLI - runTailscaleCLI = fn - t.Cleanup(func() { runTailscaleCLI = orig }) -} - -// fakeTailscale is a scripted `tailscale` CLI for registerTailscaleOnce -// tests. Each `status` call returns the next entry of statuses (the last -// one repeats); each `up` call is recorded and returns the next entry of -// upErrs (nil once they run out). -type fakeTailscale struct { - mu sync.Mutex - statuses []string - statusCalls int - upErrs []error - upCalls [][]string -} - -func (f *fakeTailscale) run(_ context.Context, args ...string) ([]byte, error) { - f.mu.Lock() - defer f.mu.Unlock() - if len(args) < 2 || args[0] != "--socket="+tailscaleSocketPath { - return nil, errors.New("unexpected tailscale argv") - } - switch args[1] { - case "status": - f.statusCalls++ - st := f.statuses[0] - if len(f.statuses) > 1 { - f.statuses = f.statuses[1:] - } - return []byte(st), nil - case "up": - f.upCalls = append(f.upCalls, args) - if len(f.upErrs) == 0 { - return nil, nil - } - err := f.upErrs[0] - f.upErrs = f.upErrs[1:] - return nil, err - } - return nil, errors.New("unexpected tailscale subcommand") -} - -func captureLog(t *testing.T) *bytes.Buffer { - t.Helper() - var buf bytes.Buffer - orig := log.Writer() - log.SetOutput(&buf) - t.Cleanup(func() { log.SetOutput(orig) }) - return &buf -} - -// TestRegisterTailscaleOnceRequestsTags asserts that when tailscaled is -// waiting in NeedsLogin, registerTailscaleOnce runs exactly one -// `tailscale up`, with exactly tailscaleUpArgs' tagged argv. -func TestRegisterTailscaleOnceRequestsTags(t *testing.T) { - fake := &fakeTailscale{statuses: []string{`{"BackendState":"NeedsLogin"}`}} - stubTailscaleCLI(t, fake.run) - - registerTailscaleOnce(context.Background(), "my-game", []string{"tag:gameplane", "tag:game"}) - - want := [][]string{{ - "--socket=/tmp/gameplane-tunnel-tailscaled.sock", - "up", - "--reset", - "--auth-key=file:/tmp/gameplane-tunnel-tailscale-authkey", - "--hostname=my-game", - "--advertise-tags=tag:gameplane,tag:game", - }} - if len(fake.upCalls) != len(want) || !equalArgs(fake.upCalls[0], want[0]) { - t.Errorf("tailscale up calls = %v, want %v", fake.upCalls, want) - } -} - -// TestRegisterTailscaleOnceWaitsThroughStarting asserts that a transient -// BackendState ("Starting") is waited out rather than acted on, and that a -// node that then reports Running with exactly the requested tags (in any -// order), as with a reused state file, gets no `tailscale up` at all. -func TestRegisterTailscaleOnceWaitsThroughStarting(t *testing.T) { - fake := &fakeTailscale{statuses: []string{ - `{"BackendState":"Starting"}`, - `{"BackendState":"Running","Self":{"Tags":["tag:game","tag:gameplane"]}}`, - }} - stubTailscaleCLI(t, fake.run) - - registerTailscaleOnce(context.Background(), "my-game", []string{"tag:gameplane", "tag:game"}) - - if len(fake.upCalls) != 0 { - t.Errorf("tailscale up calls = %v, want none for a node already running with the requested tags", fake.upCalls) - } - if fake.statusCalls != 2 { - t.Errorf("status calls = %d, want 2 (Starting, then Running)", fake.statusCalls) - } -} - -// TestRegisterTailscaleOnceRunningWithOtherTags asserts that a node -// running with different tags (or none) is re-registered with the -// requested ones. -func TestRegisterTailscaleOnceRunningWithOtherTags(t *testing.T) { - fake := &fakeTailscale{statuses: []string{`{"BackendState":"Running","Self":{"Tags":["tag:old"]}}`}} - stubTailscaleCLI(t, fake.run) - - registerTailscaleOnce(context.Background(), "my-game", []string{"tag:new"}) - - if len(fake.upCalls) != 1 || !containsArg(fake.upCalls[0], "--advertise-tags=tag:new") { - t.Errorf("tailscale up calls = %v, want one requesting tag:new", fake.upCalls) - } -} - -// TestRegisterTailscaleOnceFallsBackUntagged asserts that when the tagged -// `tailscale up` fails (e.g. no tagOwners grant) and the node is not -// Running afterwards, the failure is logged and one untagged -// `tailscale up` follows, so the tunnel still comes up. -func TestRegisterTailscaleOnceFallsBackUntagged(t *testing.T) { - fake := &fakeTailscale{ - statuses: []string{`{"BackendState":"NeedsLogin"}`}, - upErrs: []error{errors.New(`requested tags [tag:gameplane] are invalid or not permitted`)}, - } - stubTailscaleCLI(t, fake.run) - buf := captureLog(t) - - registerTailscaleOnce(context.Background(), "my-game", []string{"tag:gameplane"}) - - if len(fake.upCalls) != 2 { - t.Fatalf("tailscale up calls = %v, want 2 (tagged, then untagged)", fake.upCalls) - } - if !containsArg(fake.upCalls[0], "--advertise-tags=tag:gameplane") { - t.Errorf("first tailscale up = %v, want it to request tag:gameplane", fake.upCalls[0]) - } - wantFallback := []string{ - "--socket=/tmp/gameplane-tunnel-tailscaled.sock", - "up", - "--reset", - "--auth-key=file:/tmp/gameplane-tunnel-tailscale-authkey", - "--hostname=my-game", - } - if !equalArgs(fake.upCalls[1], wantFallback) { - t.Errorf("fallback tailscale up = %v, want %v", fake.upCalls[1], wantFallback) - } - got := buf.String() - if !strings.Contains(got, "not permitted") || !strings.Contains(got, "tagOwners") { - t.Errorf("log output = %q, want the tailscale error and the tagOwners hint", got) - } - if !strings.Contains(got, "registered the device without tags") { - t.Errorf("log output = %q, want it to report the untagged registration", got) - } -} - -// TestRegisterTailscaleOnceNoFallbackWhenStillRunning asserts that no -// untagged `tailscale up` runs when the node is still Running after the -// tagged one failed: it is already up, so a second login would be pointless. -func TestRegisterTailscaleOnceNoFallbackWhenStillRunning(t *testing.T) { - fake := &fakeTailscale{ - statuses: []string{`{"BackendState":"Running","Self":{}}`}, - upErrs: []error{errors.New("not permitted")}, - } - stubTailscaleCLI(t, fake.run) - captureLog(t) - - registerTailscaleOnce(context.Background(), "my-game", []string{"tag:gameplane"}) - - if len(fake.upCalls) != 1 { - t.Errorf("tailscale up calls = %v, want only the tagged attempt", fake.upCalls) - } -} - -// TestRegisterTailscaleOnceRunningWithOldTagsNoFallback asserts that when a -// running node retains old tags after a failed tagged attempt, no untagged -// up runs and the log explicitly surfaces the retained tags. -func TestRegisterTailscaleOnceRunningWithOldTagsNoFallback(t *testing.T) { - fake := &fakeTailscale{ - statuses: []string{`{"BackendState":"Running","Self":{"Tags":["tag:old"]}}`, `{"BackendState":"Running","Self":{"Tags":["tag:old"]}}`}, - upErrs: []error{errors.New("not permitted")}, - } - stubTailscaleCLI(t, fake.run) - buf := captureLog(t) - - registerTailscaleOnce(context.Background(), "my-game", []string{"tag:new"}) - - if len(fake.upCalls) != 1 { - t.Errorf("tailscale up calls = %v, want only the tagged attempt", fake.upCalls) - } - if !strings.Contains(buf.String(), "still running with previous tags") { - t.Errorf("log does not mention retained tags; output:\n%s", buf.String()) - } -} - -// TestStartTailscaleRegistrarReturnsAfterFailure asserts that when every -// `tailscale up` fails, registerTailscaleOnce logs and returns normally, so -// startTailscaleRegistrar's wait() (which run()'s shutdown defers on) -// completes and the relay supervision loop in run() is unaffected. -func TestStartTailscaleRegistrarReturnsAfterFailure(t *testing.T) { - fake := &fakeTailscale{ - statuses: []string{`{"BackendState":"NeedsLogin"}`}, - upErrs: []error{errors.New("not permitted"), errors.New("still failing")}, - } - stubTailscaleCLI(t, fake.run) - buf := captureLog(t) - - done := make(chan struct{}) - wait := startTailscaleRegistrar(context.Background(), "my-game", []string{"tag:gameplane"}) - go func() { - wait() - close(done) - }() - - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("startTailscaleRegistrar's wait() did not return after `tailscale up` failures; it would block run()'s shutdown") - } - - if got := buf.String(); !strings.Contains(got, "untagged `tailscale up` also failed: still failing") { - t.Errorf("log output = %q, want it to report the failed fallback", got) - } -} - -// TestRegisterTailscaleOnceStopsOnCancel asserts that a cancelled context -// ends the wait for tailscaled's socket promptly, without running -// `tailscale up`. -func TestRegisterTailscaleOnceStopsOnCancel(t *testing.T) { - var upCalled atomic.Bool - stubTailscaleCLI(t, func(_ context.Context, args ...string) ([]byte, error) { - if len(args) > 1 && args[1] == "up" { - upCalled.Store(true) - } - return nil, errors.New("dial unix /tmp/gameplane-tunnel-tailscaled.sock: connect: no such file or directory") - }) - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - registerTailscaleOnce(ctx, "my-game", []string{"tag:gameplane"}) - close(done) - }() - cancel() - - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("registerTailscaleOnce did not return after ctx was cancelled") - } - if upCalled.Load() { - t.Error("tailscale up ran after ctx was cancelled") - } -} - func TestRenderFrpConfigServerAddrEscaped(t *testing.T) { cfg := Config{ FrpServerAddr: `server.example.com"` + "\n" + `extra = "value`, @@ -1142,14 +572,6 @@ func TestRenderConfigDispatchesByType(t *testing.T) { BackingServicePort: "game:25565:30000:tcp", }, }, - { - "tailscale", - Config{ - TunnelType: "tailscale", - TailscaleHostname: "my-game", - BackingServicePorts: "game:25565", - }, - }, { "playit", Config{ @@ -1339,38 +761,6 @@ func TestBuildCommandPlayit(t *testing.T) { } } -func TestBuildCommandTailscale(t *testing.T) { - cfg := Config{TunnelType: "tailscale", TailscaleHostname: "my-game"} - cmd := buildCommand(context.Background(), cfg) - if cmd == nil { - t.Fatal("buildCommand() returned nil") - } - - // The pod has no NET_ADMIN and no /dev/net/tun, so tailscaled must run in - // userspace-networking mode -- this is load-bearing, not a style choice. - // Confirmed against tailscale/tailscale's cmd/tailscaled/tailscaled.go flags. - if !containsArg(cmd.Args, "--tun=userspace-networking") { - t.Errorf("Args = %v, missing --tun=userspace-networking", cmd.Args) - } - // Hostname and auth key travel via the declarative --config file (see - // tailscaledConfig / renderTailscaleConfig), not a flag or env var: - // tailscaled has no --hostname flag and does not read TS_AUTHKEY itself. - if !containsArg(cmd.Args, "--config="+tailscaleConfigPath) { - t.Errorf("Args = %v, missing --config=%s", cmd.Args, tailscaleConfigPath) - } - // registerTailscaleOnce reaches tailscaled over this socket; the non-root - // image can't create tailscaled's default /run/tailscale path. - if !containsArg(cmd.Args, "--socket="+tailscaleSocketPath) { - t.Errorf("Args = %v, missing --socket=%s", cmd.Args, tailscaleSocketPath) - } - if containsArg(cmd.Args, "--hostname=my-game") { - t.Errorf("Args = %v, should not pass --hostname (not a real tailscaled flag)", cmd.Args) - } - if containsEnv(cmd.Env, "TS_AUTHKEY=test-auth-key") { - t.Errorf("Env = %v, should not set TS_AUTHKEY (bare tailscaled does not read it)", cmd.Env) - } -} - func TestBuildCommandUnknownType(t *testing.T) { cfg := Config{TunnelType: "bogus"} cmd := buildCommand(context.Background(), cfg) @@ -1391,24 +781,6 @@ func equalArgs(got, want []string) bool { return true } -func containsArg(args []string, want string) bool { - for _, a := range args { - if a == want { - return true - } - } - return false -} - -func containsEnv(env []string, want string) bool { - for _, e := range env { - if e == want { - return true - } - } - return false -} - // ----------------------------------------------------------------------- // exponentialBackoff tests // ----------------------------------------------------------------------- diff --git a/tunnel/specs.md b/tunnel/specs.md index 872ced694..2527c7196 100644 --- a/tunnel/specs.md +++ b/tunnel/specs.md @@ -2,48 +2,52 @@ **Status:** pre-v1 (v0.3.0) **Module / command:** `github.com/GameplanePanel/gameplane/tunnel` -**Dependencies:** stdlib only +**Dependencies:** Go stdlib and `tailscale.com/tsnet` (F-173, #722) ## Purpose -Relay client supervisor that configures and supervises a third-party tunnel process (frp, Tailscale, or playit). The supervisor runs as a single-replica Deployment per GameServer, reads provider-specific configuration and credentials via environment variables and a mounted Secret, renders provider-specific config files, spawns the relay binary, and supervises it with exponential backoff until context cancellation or an unrecoverable error. The tunnel pod never sleeps — it holds the public relay address across the full GameServer lifecycle so connection attempts can trigger wake-on-connect. +Relay client supervisor that configures and supervises frp/playit child processes or an embedded Tailscale node. The supervisor runs as a single-replica Deployment per GameServer, reads provider-specific configuration and credentials via environment variables and a mounted Secret, renders provider-specific config files, spawns the relay binary, and supervises it with exponential backoff until context cancellation or an unrecoverable error. The tunnel pod never sleeps — it holds the public relay address across the full GameServer lifecycle so connection attempts can trigger wake-on-connect. ## Responsibilities 1. Parse environment variables to determine the tunnel provider (frp, tailscale, or playit), backing service address, and provider-specific config. 2. Read provider-specific credentials from the mounted Secret at `/etc/gameplane/tunnel-auth` (read-only). -3. Render provider-specific config files at fixed paths (`/tmp/gameplane-tunnel-frpc.toml` for frp, `/tmp/gameplane-tunnel-tailscaled.json` for tailscale, `/tmp/gameplane-tunnel-playit-auth` for playit). -4. Build provider-specific relay command-line arguments, referencing only fixed config file paths (never passing secrets via argv). -5. Spawn the relay binary (frpc, tailscaled, or playitd) and supervise it until context cancellation or unrecoverable failure. +3. Render frp TOML or playit credentials at fixed paths, with secrets excluded from argv. +4. Start and supervise frpc or playitd as child processes. +5. For Tailscale, authenticate an embedded tsnet node, open advertised TCP/UDP listeners, and forward only to the GameServer's backing Service. 6. On transient failure (exit code other than 126/127, not a permission error), apply exponential backoff (2^n seconds, capped at 300 seconds) and restart. 7. On unrecoverable failure (exit code 126/127 or "permission denied" error), exit immediately. 8. Forward SIGTERM for graceful shutdown, waiting up to 10 seconds before SIGKILL. 9. (Playit only) Poll playitd's IPC control socket for the assigned relay addresses and patch them into the GameServer's `status.tunnelEndpoints`, which the operator validates and merges into `status.endpoints` (see "Playit Address Reporting"). -10. (Tailscale only, when `TAILSCALE_TAGS` holds valid tags) Register the device once with `tailscale up --advertise-tags` over tailscaled's socket, requesting the tags at registration. If the tailnet refuses them, log the error; only if the backend is not `Running` afterwards, attempt one untagged registration, which can also fail and is then logged (see "Tailscale Tag Application"). +10. Tailscale readiness requires an online tailnet identity with the requested hostname/tags and all forwarding listeners active. Invalid or denied tags remain not ready; there is no untagged fallback. ## Non-goals / boundaries - Does **not** run the game server itself — that is the operator and game container's job. - Does **not** create or manage the relay infrastructure — frp/Tailscale/playit accounts and credentials are pre-configured. - Does **not** authenticate or authorize clients connecting to the relay — relay access control is the provider's responsibility. -- Does **not** validate or filter traffic — the relay process handles all inbound connections and port forwarding. +- Does **not** validate or filter traffic — the provider handles access control; the Tailscale bridge copies bytes/datagrams to fixed destinations. - Does **not** modify the game pod or game container — the tunnel runs as a separate Deployment. - Does **not** report metrics or logs to external systems — logs flow to the pod's stdout/stderr for the cluster to capture. - Does **not** support runtime configuration changes — config is read once at startup and cannot be updated without redeploying. -- Does **not** implement Tailscale, frp, or playit protocols — delegates to their official binaries. +- Does **not** implement Tailscale, frp, or playit protocols — uses official frp/playit binaries and the official Tailscale Go library. ## Directory & package layout ``` tunnel/ -├── main.go # Entry point; config loading; credential reading; config rendering; relay spawning and supervision; tailscale tagged registration +├── main.go # Entry point; config loading; credential reading; config rendering; relay spawning and supervision +├── tailscale.go # Embedded node lifecycle, health and identity checks +├── tailscale_forward.go # Bounded TCP/UDP bridge to the fixed Service +├── tailscale_test.go # Registration configuration and readiness lifecycle +├── tailscale_forward_test.go # Real TCP/UDP sockets, isolation, limits and shutdown ├── playit_reporter.go # playit only: polls playitd's IPC socket for assigned addresses; patches status.tunnelEndpoints ├── playit_reporter_test.go # Fake playitd IPC server + httptest API server tests for the reporter ├── main_test.go # Config parsing; credential reading; config rendering per provider; command building; backoff; error classification; supervision lifecycle ├── Dockerfile.frp # Image build for frp provider (sets Version via -ldflags) ├── Dockerfile.playit # Image build for playit provider (one image per provider) ├── Dockerfile.tailscale # Image build for tailscale provider -├── go.mod # Dependencies: stdlib only (no external deps, so no go.sum) +├── go.mod # Pinned tsnet dependency; go.sum records the transitive graph └── .testcoverage.yml # 70% coverage gate ``` @@ -63,8 +67,8 @@ Single executable module; no subdirectories or packages. | `FRP_SERVER_PORT` | int | no (default 7000) | frp server port; validated 1–65535 | | `BACKING_SERVICE_PORT` | string | if frp | Port mappings for frp, format `name:localPort:remotePort:protocol,...` (e.g., `game:34197:30000:udp`); localPort and protocol come from the GameTemplate port, remotePort from `spec.networking.tunnel.frp.remotePorts` (F-052) | | `TAILSCALE_HOSTNAME` | string | if tailscale | MagicDNS hostname in the tailnet; fatal if unset for tailscale provider | -| `TAILSCALE_TAGS` | string | no | Comma-separated ACL tags requested for the Tailscale device when it registers. Each entry is trimmed, empty entries are dropped, and a bare name gets the `tag:` prefix. Each tag must then be `tag:` followed by a letter and only letters, digits or `-` (tailscale's `tailcfg.CheckTag` rule); otherwise the whole value is logged and ignored, and the device registers untagged. Never written into tailscaled's declarative config: its alpha0 config has no field for ACL tags (`ipn.ConfigVAlpha`), and its loader rejects unknown JSON fields, so a `tags` key would make tailscaled refuse to start. Tags are requested through a one-off `tailscale up --advertise-tags` instead (see "Tailscale Tag Application"). The tailnet's ACL must grant `tagOwners` for the tags to the auth key's owner. If it doesn't, the error is logged and the tunnel runs untagged rather than crash-looping. | -| `BACKING_SERVICE_PORTS` | string | if tailscale or playit | Container ports for non-frp providers, format `name:port,name:port` | +| `TAILSCALE_TAGS` | string | no | Comma-separated requested tags. Whitespace/empty entries are removed, bare names receive `tag:`, duplicates are removed. Invalid tags fail startup. Requested tags must be granted by tailnet `tagOwners`; refusal does not fall back to an untagged identity. | +| `BACKING_SERVICE_PORTS` | string | if tailscale or playit | Tailscale: `name:port:protocol,...`, only `tcp` and `udp`, at most 32 unique protocol/port pairs. Playit retains `name:port,...`. Upgrade operator and tunnel images together; ambiguous legacy Tailscale mappings fail closed. | | `PLAYIT_TUNNEL_NAME` | string | if playit | Label/name for the playit tunnel; fatal if unset for playit provider | **Validation**: At startup, `loadConfig` enforces mutual requirement rules: frp mandates `FRP_SERVER_ADDR` and `BACKING_SERVICE_PORT`; tailscale mandates `TAILSCALE_HOSTNAME` and `BACKING_SERVICE_PORTS`; playit mandates `PLAYIT_TUNNEL_NAME` and `BACKING_SERVICE_PORTS`. Invalid `FRP_SERVER_PORT` values (non-numeric, out of range 1–65535) are fatal. Unknown `TUNNEL_TYPE` values are fatal. @@ -76,7 +80,7 @@ The operator mounts a read-only Secret volume at `/etc/gameplane/tunnel-auth` co | Provider | Key Name | Example Usage | Notes | |---|---|---|---| | frp | `token` | Auth token for the frpc client | Read by `readCredentials` and embedded in rendered `/tmp/gameplane-tunnel-frpc.toml` | -| tailscale | `authKey` | Auth key (one-time or reusable) for device registration | Read and embedded in rendered `/tmp/gameplane-tunnel-tailscaled.json` | +| tailscale | `authKey` | Reusable auth key for registration | Read into tsnet.Server.AuthKey in memory; identity state is stored in the Pod volume | | playit | `secretKey` | API secret key for playitd authentication | Read and written to `/tmp/gameplane-tunnel-playit-auth` for playitd's `--secret-path` flag | Whitespace (leading/trailing newlines, spaces) is trimmed from credential values before use. Missing credentials are fatal errors and prevent relay startup. @@ -86,18 +90,15 @@ Whitespace (leading/trailing newlines, spaces) is trimmed from credential values | Provider | Path | Format | Ownership | |---|---|---|---| | frp | `/tmp/gameplane-tunnel-frpc.toml` | TOML | Rendered by `renderFrpConfig`; read by frpc process | -| tailscale | `/tmp/gameplane-tunnel-tailscaled.json` | JSON | Rendered by `renderTailscaleConfig`; read by tailscaled via `--config` flag. Holds `version` and `hostname`, plus `authKey` without tags, or `"locked": false` with tags | -| tailscale (tags only) | `/tmp/gameplane-tunnel-tailscale-authkey` | Raw text (auth key) | Written by `renderTailscaleConfig` when tags are requested; read by `tailscale up --auth-key=file:` | | playit | `/tmp/gameplane-tunnel-playit-auth` | Raw text (secret key) | Rendered by `renderPlayitConfig`; read by playitd via `--secret-path` flag | -All files are written with mode `0o600` (read/write by owner only) via `os.WriteFile` — see `renderFrpConfig`, `renderTailscaleConfig`, `renderPlayitConfig` in `main.go`. The rendered config file (and, for tailscale, the auth key file) is removed by a deferred `os.Remove` when the supervisor's `run` returns (context cancellation or an unrecoverable error); it is not removed on SIGKILL or on each relay restart inside the backoff loop. tailscaled's `--state` file (`/tmp/tailscale.state`) is never removed by the process. All of these files live under `/tmp` in the pod's ephemeral, per-pod filesystem, so any that remain are discarded when the pod terminates and its filesystem is reclaimed. +frp/playit rendered files use mode 0600 and are removed when `run` returns. Tailscale uses the state volume described below; no daemon config or CLI auth file is produced. ### Relay Binaries and Command-Line Arguments | Provider | Binary | Arguments | |---|---|---| | frp | `/usr/local/bin/frpc` | `-c /tmp/gameplane-tunnel-frpc.toml` (config file flag) | -| tailscale | `/usr/local/bin/tailscaled` | `--tun=userspace-networking` (hardened pod context), `--state=/tmp/tailscale.state`, `--config=/tmp/gameplane-tunnel-tailscaled.json`, `--socket=/tmp/gameplane-tunnel-tailscaled.sock` | | playit | `/usr/local/bin/playitd` | `--secret-path /tmp/gameplane-tunnel-playit-auth`, `--socket-path /tmp/gameplane-tunnel-playitd.sock`, `--platform-docker` | All binary paths are fixed constants, not configurable. Secrets are never passed via command-line arguments (gosec G204 compliance). @@ -112,27 +113,26 @@ playit.gg assigns a tunnel's public address server-side, so the supervisor learn 4. When the mapped set differs from the last one reported, it's merge-patched into `status.tunnelEndpoints` on `PATCH https://kubernetes.default.svc/apis/gameplane.local/v1alpha1/namespaces//gameservers//status`. The request uses the pod's projected ServiceAccount token and CA from `/var/run/secrets/kubernetes.io/serviceaccount`. The first report after startup is always sent, and an empty set is sent as `null` so stale addresses from a previous pod are cleared. The operator validates those entries (`validatePlayitEndpoints`) and merges them into `status.endpoints`, the same place frp/tailscale endpoints land. 5. Timing: while the socket is down, playitd isn't `running` yet, or a patch fails, the reporter backs off from 1s, doubling up to 30s. Once a set has been reported, it re-polls every 30s so address changes are picked up. It stops when `run`'s context ends. If the ServiceAccount CA can't be read (not in a cluster) or `BACKING_SERVICE_PORTS` doesn't parse, the reporter logs and is skipped. The relay still runs. -### Tailscale Tag Application +### Embedded Tailscale forwarding (F-173 / #722) -Tailscale ACL tags can only be requested when a device registers. tailscaled's declarative `--config` file has no field for them (see the `TAILSCALE_TAGS` row above). `tailscale set` has no `--advertise-tags` flag, and changing the tags of a registered node needs a fresh login. So when `TAILSCALE_TAGS` holds valid tags, the supervisor lets tailscaled start unauthenticated and registers it itself. Flags below are confirmed against tailscale v1.104.2, the version `Dockerfile.tailscale` ships. +The maintainer chose embedded `tailscale.com/tsnet` for TCP and UDP; the decision is recorded in [OPEN-DECISIONS.md](../specs/done_018-v0-3-release-readiness/OPEN-DECISIONS.md#od-031-tailscale-game-forwarding-f-173--resolved-2026-10-21). No tailscaled executable, CLI, subnet router, exit node, Serve or Funnel configuration is used. -1. `renderTailscaleConfig` writes the config with `version`, `hostname` and `"locked": false`, and no `authKey`. The auth key goes to `/tmp/gameplane-tunnel-tailscale-authkey` (mode 0600). `locked` must be false: `ipn.ConfigVAlpha.Locked` defaults to true, and a locked config makes tailscaled reject every CLI prefs change. -2. `tailscaled` is started as usual, with `--socket=/tmp/gameplane-tunnel-tailscaled.sock`. Its Linux default, `/run/tailscale/tailscaled.sock`, can't be created by the non-root distroless image; this is the same reasoning as `playitSocketPath`. The `tailscale` CLI, bundled in `Dockerfile.tailscale` alongside `tailscaled`, reaches it over the same socket via `--socket=`. With no auth key, tailscaled waits in `NeedsLogin`. -3. `run` starts one goroutine, `startTailscaleRegistrar`, which calls `registerTailscaleOnce` once per supervisor lifetime, in parallel with the relay supervision loop. It is not rerun on a relay restart: the login and granted tags persist in tailscaled's state file (`/tmp/tailscale.state`, `--state` in `buildCommand`). -4. `waitForTailscaled` polls `tailscale --socket= status --json --peers=false` every `tailscaleReadyPollInterval` (2 seconds), bounded by `tailscaleReadyTimeout` (2 minutes). It waits until the socket answers with a settled `BackendState`: `NeedsLogin`, `NeedsMachineAuth`, `Stopped` or `Running`. Transient states (`NoState`, `Starting`) keep it polling. -5. If the node is already `Running` and `Self.Tags` is exactly the requested set, as with a reused state file, nothing more happens. -6. Otherwise it runs `tailscale --socket= up --reset --auth-key=file:/tmp/gameplane-tunnel-tailscale-authkey --hostname= --advertise-tags=` once, bounded by `tailscaleUpTimeout` (2 minutes). The pure `tailscaleUpArgs` builds this argv. `--auth-key=file:` makes the CLI read the key from the file, so the key never appears in argv. `--reset` resets every setting not named on the command line to its default. Without it, `tailscale up` on a node with saved prefs refuses to run unless every non-default setting is repeated. -7. If that fails, most commonly because the tailnet's ACL doesn't grant `tagOwners` for the tags to the auth key's owner, the error is logged. If the node is not `Running` afterwards, `tailscale up` runs once more without `--advertise-tags`, so the tunnel registers untagged. Every failure, including the readiness timeout, is logged and none stops the supervisor or the relay process. An ACL problem only the tailnet admin can fix never becomes a crash loop. +- `tailscaleForwardPlan` accepts only the exact `..svc` host and explicitly advertised protocol/port pairs. Inbound bytes never select a backend address. TCP listens on both tailnet families; UDP binds each assigned tailnet IP because tsnet requires an explicit address. +- The ordinary Go network dialer resolves the Service for each new TCP connection or UDP session. Existing streams/sessions can fail during Service/backend replacement; reconnecting clients resolve the current Service. UDP sessions expire after 30 seconds without client packets. +- A shared 128-session budget covers all ports/protocols/families. Each UDP session queues at most four datagrams (65,507 bytes each) and uses one response buffer. Full budgets or queues drop incoming UDP; excess TCP connections close. Dials and UDP writes time out after five seconds. TCP sockets have five-minute activity timeouts. Shutdown closes listeners and sockets, cancels pending dials, then joins workers. +- `tsnet.Server` receives the existing hostname, normalized requested tags and mounted auth key. There is no untagged fallback. The fixed state directory `/var/lib/gameplane-tailscale` uses a per-Pod EmptyDir owned through fsGroup 65532. Container restarts reuse identity. Pod replacement loses state and needs a reusable valid auth key; the Deployment uses Recreate. This is not durable identity across Pod replacement. Remove stale devices in the tailnet admin console to free the hostname. A suffixed/conflicting MagicDNS name cannot report the originally advertised name as ready. +- `Up` has a two-minute attempt deadline; failed attempts use the existing capped retry delay. After binding every listener, the binary polls local tailnet status every two seconds with a two-second deadline. Loss of online status, tags or hostname clears readiness. Address changes recreate listeners. Failed listeners close all forwarding sockets and retry. Credentials and interactive login URLs are excluded from logs. +- HTTP `:8091/readyz` returns 200 only while that state is ready, otherwise 503. `/healthz` remains independent of tailnet availability. The operator probes every five seconds and requires a current observed Deployment generation with an updated ready replica. Legacy deployments without the forwarding probe cannot report `TunnelReady=True`. Tailscale endpoints are published only while ready. +- Readiness establishes local forwarding capacity and tailnet authentication. It does not prove a particular player's grant or game application health, and it deliberately remains ready while the game sleeps so traffic can reach the sentinel. Tailnet grants/ACLs still determine which peers may connect. Missing tags, denied registration, device approval and hostname conflicts require administrator repair. +- Upgrading from the former daemon implementation re-registers the Pod. Replace the auth key first if it was single-use. Coordinate operator and tunnel image upgrades; the new protocol-bearing port contract rejects old ambiguous entries. -The CLI is run by `execTailscaleCLI` with `exec.CommandContext` tied to `run`'s context. The binary path is the literal `/usr/local/bin/tailscale`, and nothing goes through a shell. The argv is appended to `cmd.Args`, so gosec's G204 check sees only literal arguments. The actual guarantee is that argv is built only by `tailscaleStatusArgs` and `tailscaleUpArgs`: every value is a single `--flag=value` element, the tags are validated by `parseTailscaleTags`, and the auth key is never in argv. - -Without tags, none of this runs. The auth key goes into the config and tailscaled logs in by itself, as before. +The disposable integration procedure in [tailscale-forwarding-integration.md](../docs/tailscale-forwarding-integration.md) covers actual tailnet TCP/UDP, denied grants, backend replacement and registration/restart outcomes. Unit transports use real local sockets; they do not establish live tailnet coverage. ## Key Invariants 1. **Tunnel pod never sleeps.** The tunnel Deployment persists for the full GameServer lifecycle, even when the server idles or suspends. The pod must remain running to hold the relay address so a connection attempt can trigger wake-on-connect. -2. **Credentials are never passed via command-line.** All three providers use file-based credential delivery: frp and tailscale embed credentials in rendered config files, playitd reads a secret file via `--secret-path`. This guards against argv scraping and satisfies gosec's G204 (subprocess argument constant verification). +2. **Credentials are never passed via command-line.** frp embeds credentials in a private config file, playitd reads a secret file via `--secret-path`, and tsnet receives its key in memory. This guards against argv scraping and satisfies gosec's G204 (subprocess argument constant verification). 3. **Exponential backoff is capped and unshed.** On transient failure, retry delays follow 2^n seconds (1s, 2s, 4s, ..., 256s) capped at 300 seconds (5 minutes). No jitter is added because each pod runs a single replica against its own relay connection; jitter spreads out a thundering herd, not a singleton. @@ -144,10 +144,9 @@ Without tags, none of this runs. The auth key goes into the config and tailscale 7. **Operator image selection per provider.** The operator injects provider-specific tunnel images via flags (`--tunnel-frp-image`, `--tunnel-tailscale-image`, `--tunnel-playit-image`), each with a `dev` default. Each pod runs exactly one provider's binary, selected by `TUNNEL_TYPE`. -## Known Gaps +## Known Limits -- **Tailscale doesn't forward tailnet traffic to the game (F-173):** `BACKING_SERVICE_DNS` and `BACKING_SERVICE_PORTS` are read and validated for presence when `TUNNEL_TYPE=tailscale`, but neither reaches `renderTailscaleConfig` or the `tailscaled` process. The rendered config carries only `version`, `hostname`, and `authKey` or `locked`. ACL tags are requested at registration through `tailscale up`; see "Tailscale Tag Application". The tunnel pod registers as a tailnet device, but nothing bridges an inbound tailnet connection to the backing Service — tailscaled's declarative `--config` file has no serve/forward field, and `tailscale serve` (the real mechanism) only proxies TCP/HTTP(S) to a local `127.0.0.1` target, never a remote host, and has no UDP support. Fixing this is an architecture decision (a local proxy + headless `tailscale serve`, TCP-only; a subnet route via `advertiseRoutes`, which needs tailnet ACL auto-approval and widens reachability into cluster-internal networking; or replacing subprocess `tailscaled` with an embedded `tsnet` listener, which would end this module's stdlib-only dependency policy), tracked pending a decision in `specs/done_018-v0-3-release-readiness/OPEN-DECISIONS.md`. -- **Single-use Tailscale auth keys and tag retries:** when `tags` is set, the supervisor logs in with `tailscale up`. If that tagged login is refused (missing `tagOwners`), or a restarted pod reuses a state file with different tags, the untagged fallback `up` reuses the same auth key; a single-use (ephemeral, non-reusable) key has already been consumed, so the fallback fails too. The failure is logged and the supervisor does not crash-loop, but the device stays logged out until a fresh key is stored. Use a reusable auth key when setting `tags`. +Tailscale identity survives container restarts but not Pod replacement; a reusable auth key and free MagicDNS hostname are required for re-registration. Live tailnet integration needs an explicitly provisioned disposable tailnet and is separate from ordinary unit CI. The prior registration-only F-173 gap is replaced by the embedded TCP/UDP bridge above. ## CRD Integration @@ -227,16 +226,16 @@ When the pod receives SIGTERM (e.g., during cluster shutdown or pod deletion): ## Dependencies **Internal:** None -**External:** Go stdlib only (bufio, bytes, context, crypto/tls, crypto/x509, encoding/json, errors, fmt, io, log, net, net/http, net/url, os, os/exec, os/signal, path/filepath, reflect, sort, strconv, strings, sync, syscall, time). The playit status patch uses plain `net/http` against the API server rather than client-go, keeping the module free of third-party dependencies. -**Go version:** 1.26+ +**External:** `tailscale.com/tsnet` v1.104.2 and Go stdlib. The playit status patch uses plain `net/http` against the API server without adding client-go. +**Go version:** 1.27.3+ (the minimum required by Tailscale v1.104.2) -No third-party dependencies. The operator provides provider-specific binaries (frpc, tailscaled, playitd) in each container image. +The maintainer explicitly approved ending the stdlib-only policy for tsnet in #722. Version 1.104.2 preserves the upstream Tailscale security update while retaining the embedded TCP/UDP forwarding implementation. CI resolves the latest Go 1.27 patch; the workspace and tunnel module require at least Go 1.27.3, and tunnel image builders use Go 1.27.3. Track future tsnet/security updates through go.mod/go.sum. frpc and playitd remain external binaries. ## Security Considerations 1. **Secret mounting (read-only):** Credentials are mounted from Kubernetes Secrets into the pod at `/etc/gameplane/tunnel-auth` as a read-only VolumeMount. The credential files are accessed only after a path-containment check via `filepath.Rel` to ensure the resolved path does not escape the mount directory, providing defense-in-depth against directory traversal. -2. **Credentials never on argv:** All three providers accept secrets via file-based config (embedded in TOML/JSON for frp/tailscale, separate `--secret-path` file for playitd). This ensures secrets don't appear in `/proc//cmdline`. +2. **Credentials never on argv:** frp/playit accept secrets via private files; Tailscale receives the mounted key in memory. This ensures secrets don't appear in `/proc//cmdline`. 3. **Hardened pod security context:** UID 65532 (nonroot), runAsNonRoot=true, allowPrivilegeEscalation=false, all capabilities dropped. No privileged escalation or host access. @@ -244,7 +243,7 @@ No third-party dependencies. The operator provides provider-specific binaries (f 5. **Network egress policy:** The tunnel pod's outbound traffic is controlled by a per-server NetworkPolicy. Without it, the games namespace's default-deny-egress would block relay connections. -6. **Image provider isolation:** Each provider's tunnel binary (frpc, tailscaled, playitd) runs in its own container image. A vulnerability in one provider's binary doesn't affect others; operators can patch images independently. +6. **Image provider isolation:** Provider images remain separate. The shared Go executable now links tsnet; all three tunnel images must be rebuilt for a dependency security update. 7. **No credential validation at tunnel pod level:** The tunnel supervisor does not validate frp tokens, Tailscale auth keys, or playit secret keys. Validation happens at the provider's server. Invalid credentials manifest as authentication failures on the provider side. @@ -255,9 +254,9 @@ No third-party dependencies. The operator provides provider-specific binaries (f - **Config loading:** `TestLoadConfigFrp`, `TestLoadConfigTailscale`, `TestLoadConfigPlayit` verify environment variable parsing and provider-specific validation (required fields per provider, default values). - **Config validation errors:** `TestLoadConfigMissingRequired`, `TestLoadConfigFrpMissingAddress`, `TestLoadConfigInvalidFrpPort`, `TestLoadConfigTailscaleMissingHostname`, `TestLoadConfigPlayitMissingTunnelName` verify that missing or invalid required fields are fatal with clear error messages. - **Credential reading:** `TestReadCredentialsSuccess`, `TestReadCredentialsMissingFile`, `TestReadCredentialsKeyNames`, `TestReadCredentialsUnknownTunnelType` verify credential path isolation (defense-in-depth `filepath.Rel` check) and per-provider key names. -- **Config rendering:** `TestRenderFrpConfig`, `TestRenderFrpConfigInvalidPortMapping`, `TestRenderFrpConfigMultiplePorts`, `TestRenderTailscaleConfig`, `TestRenderTailscaleConfigNoHostname`, `TestRenderTailscaleConfigWithTagsOmitsTags`, `TestRenderTailscaleConfigNoTags`, `TestRenderTailscaleConfigInvalidTagsRegistersUntagged`, `TestRenderTailscaleConfigLogTagsNotesRegistration`, `TestRenderTailscaleConfigLogTagsSilentWhenUnset` and `TestRenderPlayitConfig` verify the TOML/JSON/text output for each provider. The tailscale tests also check that no `tags` key is ever written into tailscaled's config. With valid tags, `authKey` is left out, `"locked": false` is written, and the key goes to a 0600 file. Without tags or with invalid tags, the config keeps its original shape and no key file is written. The log line names `tailscale up --advertise-tags` and the `tagOwners` requirement. -- **Command building:** `TestBuildCommandFrp`, `TestBuildCommandPlayit`, `TestBuildCommandTailscale`, `TestBuildCommandUnknownType` verify that command-line arguments are correctly assembled and that secrets never appear in argv. -- **Tailscale tagged registration:** `TestParseTailscaleTags` (table-driven) checks that tags are trimmed, empty entries dropped and bare names given the `tag:` prefix, and that invalid tags are rejected: empty names, a leading digit, spaces, other prefixes, flag-like values and shell metacharacters. `TestTailscaleUpArgs` and `TestTailscaleStatusArgs` check the exact argv. The auth key never appears in it. `TestRegisterTailscaleOnceRequestsTags` checks that exactly one tagged `tailscale up` runs from `NeedsLogin`. `TestRegisterTailscaleOnceWaitsThroughStarting` checks that a transient state is polled past and that a node already `Running` with the same tags is skipped. `TestRegisterTailscaleOnceRunningWithOtherTags` checks that a node with different tags is re-registered. `TestRegisterTailscaleOnceFallsBackUntagged` checks that the tagged failure is logged with the `tagOwners` hint and followed by the exact untagged argv. `TestRegisterTailscaleOnceNoFallbackWhenStillRunning` checks that no second login runs on a node that is still up. `TestStartTailscaleRegistrarReturnsAfterFailure` checks that `wait()` returns after failures, so `run`'s shutdown is never blocked. `TestRegisterTailscaleOnceStopsOnCancel` checks that cancellation ends the wait without running `up`. +- **Config rendering:** frp and playit tests verify private file formats, escaping, credential delivery and invalid mappings. +- **Command building:** frp/playit tests check argv and fixed paths. +- **Tailscale:** local socket regressions cover bidirectional TCP/UDP, peer isolation, shared session limits, idle eviction and shutdown. Lifecycle tests cover hostname/tag configuration, denied registration/tags, dual-stack listener creation, partial bind cleanup, online/offline readiness and hostname collisions. Operator tests verify protocol projection, readiness generation/probe requirements, unsupported protocols and EmptyDir/Recreate deployment wiring. - **Exponential backoff:** `TestExponentialBackoff`, `TestExponentialBackoffCap` verify delay calculations and the 300-second cap. - **Error classification:** `TestIsUnrecoverable` verifies that exit codes 126/127 and "permission denied" errors are unrecoverable, and that other errors trigger retry. - **Supervision lifecycle:** `TestRunContextCancellation`, `TestRunTransientFailureBacksOffThenCancels`, `TestRunRenderConfigFailure`, `TestRunReadCredentialsFailure`, `TestRunPlayitConfigDispatch` verify clean context cancellation, backoff retry, and error path handling. @@ -267,12 +266,12 @@ No third-party dependencies. The operator provides provider-specific binaries (f **Test doubles:** - **`withCredentialsDir`:** Temporary directory helper that repoints the package-level `credentialsDir` var at `t.TempDir()` for the duration of a test, allowing credential reads to be exercised without touching the real mount point. -- **`stubTailscaleCLI` / `fakeTailscale`:** `stubTailscaleCLI` points the package-level `runTailscaleCLI` var at a fake `func(ctx, args...) ([]byte, error)` for the duration of a test. This is the same seam pattern as `credentialsDir`. `fakeTailscale` scripts the `status --json` replies and `up` results and records each `up` argv, so `waitForTailscaled` and `registerTailscaleOnce` can be tested without running a real `tailscale` binary. +- **`fakeTailnetNode`:** controls registration/status outcomes while opening real local listeners; no provider credentials are needed. - **Real system binaries in some tests:** `TestRunCommandContextCancellation` uses the real `sleep` command (no relay binary needed) to verify that context cancellation triggers SIGTERM forwarding, allowing the child to exit cleanly within the 10-second grace period. **Coverage gate:** 70% per `.testcoverage.yml`. Uncovered paths include error cases in relay binary spawning (operator-level validation and pod constraints already catch most configuration issues before the tunnel binary runs) and platform-specific signal handling edge cases. -**Coverage rationale:** The tunnel supervisor's primary responsibility is configuration management and child process supervision. Tests cover all config parsing, credential reading, config rendering, command building, and supervision loop paths. The relay binaries themselves (frpc, tailscaled, playitd) are third-party and tested by their own projects. The tunnel pod's job is to correctly invoke them and restart on failure; actual relay functionality is end-to-end tested at the e2e suite level. +**Coverage rationale:** The tunnel supervisor's primary responsibility is configuration management and child process supervision. Tests cover all config parsing, credential reading, config rendering, command building, and supervision loop paths. frpc/playitd and the embedded tsnet library are third-party; local socket tests cover Gameplane's bridge. The tunnel pod's job is to correctly invoke them and restart on failure; actual relay functionality is end-to-end tested at the e2e suite level. ## References diff --git a/tunnel/tailscale.go b/tunnel/tailscale.go new file mode 100644 index 000000000..720641562 --- /dev/null +++ b/tunnel/tailscale.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "net/netip" + "slices" + "strings" + "sync/atomic" + "time" + + "tailscale.com/ipn/ipnstate" + "tailscale.com/tsnet" +) + +const tailscaleStateDir = "/var/lib/gameplane-tailscale" + +type tailnetNode interface { + Up(context.Context) (*ipnstate.Status, error) + Status(context.Context) (*ipnstate.Status, error) + Listen(string, string) (net.Listener, error) + ListenPacket(string, string) (net.PacketConn, error) +} + +type embeddedTailnet struct{ *tsnet.Server } + +func (s embeddedTailnet) Up(ctx context.Context) (*ipnstate.Status, error) { + status, err := s.Server.Up(ctx) + if err != nil { + return nil, err + } + return checkTailnetHostname(status, s.Hostname) +} + +func (s embeddedTailnet) Status(ctx context.Context) (*ipnstate.Status, error) { + client, err := s.LocalClient() + if err != nil { + return nil, err + } + status, err := client.StatusWithoutPeers(ctx) + if err != nil { + return nil, err + } + return checkTailnetHostname(status, s.Hostname) +} + +func checkTailnetHostname(status *ipnstate.Status, hostname string) (*ipnstate.Status, error) { + if status == nil || status.Self == nil { + return nil, errors.New("tailnet identity unavailable") + } + name, _, _ := strings.Cut(status.Self.DNSName, ".") + if name != hostname { + return nil, errors.New("tailnet hostname differs from advertised endpoint; enable MagicDNS and remove conflicting device names") + } + return status, nil +} + +func tailscaleServer(cfg Config, authKey string) (*tsnet.Server, []string, error) { + tags, err := parseTailscaleTags(cfg.TailscaleTags) + if err != nil { + return nil, nil, fmt.Errorf("tailscale tags: %w", err) + } + if cfg.TailscaleHostname == "" || authKey == "" { + return nil, nil, errors.New("tailscale hostname and authKey are required") + } + return &tsnet.Server{Hostname: cfg.TailscaleHostname, AuthKey: authKey, AdvertiseTags: tags, Dir: tailscaleStateDir, + // Neither verbose daemon logs nor interactive login URLs belong in pod + // logs. Readiness and sanitized retry messages expose failure instead. + Logf: func(string, ...any) {}, UserLogf: func(string, ...any) {}, + }, tags, nil +} + +func runTailscale(ctx context.Context, cfg Config, authKey string) error { + ports, err := tailscaleForwardPlan(cfg) + if err != nil { + return err + } + var ready atomic.Bool + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":8091") + if err != nil { + return fmt.Errorf("tailscale health listener: %w", err) + } + health := &http.Server{Handler: tailscaleHealth(&ready), ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, IdleTimeout: 10 * time.Second} + healthDone := make(chan error, 1) + go func() { healthDone <- health.Serve(listener) }() + defer func() { _ = health.Close() }() + backoff := &exponentialBackoff{} + for ctx.Err() == nil { + server, tags, err := tailscaleServer(cfg, authKey) + if err != nil { + return err + } + err = serveTailnet(ctx, embeddedTailnet{server}, tags, ports, &ready, 2*time.Second) + ready.Store(false) + _ = server.Close() + if ctx.Err() != nil { + return nil + } + // Up errors may embed registration responses. Keep credentials and + // login URLs out of logs; the operator exposes a pending condition. + if err != nil { + log.Print("Tailscale forwarding unavailable; check credentials, requested tagOwners, device approval, unique hostname, MagicDNS and tailnet connectivity") + } + select { + case <-ctx.Done(): + return nil + case err := <-healthDone: + return fmt.Errorf("tailscale health server: %w", err) + case <-time.After(backoff.next()): + } + } + return nil +} + +func tailscaleHealth(ready *atomic.Bool) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) { + if !ready.Load() { + http.Error(w, "tailnet or forwarding listeners unavailable", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + }) + return mux +} + +func tailnetReady(status *ipnstate.Status, tags []string) bool { + if status == nil || status.BackendState != "Running" || status.Self == nil || !status.Self.Online || len(status.TailscaleIPs) == 0 { + return false + } + for _, tag := range tags { + if status.Self.Tags == nil || !slices.Contains(status.Self.Tags.AsSlice(), tag) { + return false + } + } + return true +} + +func serveTailnet(ctx context.Context, node tailnetNode, tags []string, ports []forwardPort, ready *atomic.Bool, poll time.Duration) error { + ready.Store(false) + defer ready.Store(false) + upCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + status, err := node.Up(upCtx) + cancel() + if err != nil { + return fmt.Errorf("join tailnet: %w", err) + } + if !tailnetReady(status, tags) { + return errors.New("tailnet is not online with the requested tags") + } + ips := append([]netip.Addr(nil), status.TailscaleIPs...) + f := newForwarder(ctx, defaultForwardLimits) + defer f.Close() + for _, port := range ports { + if port.network == "tcp" { + listener, err := node.Listen("tcp", ":"+port.port) + if err != nil { + return fmt.Errorf("TCP listener %s: %w", port.port, err) + } + f.serveTCP(listener, port.target) + } else { + // tsnet requires an explicit IP for UDP. Bind both families so + // MagicDNS choosing IPv6 cannot bypass the advertised datapath. + for _, ip := range ips { + listener, err := node.ListenPacket("udp", net.JoinHostPort(ip.String(), port.port)) + if err != nil { + return fmt.Errorf("UDP listener %s: %w", port.port, err) + } + f.serveUDP(listener, port.target) + } + } + } + ready.Store(true) + ticker := time.NewTicker(poll) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case err := <-f.failed: + return err + case <-ticker.C: + statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + status, err := node.Status(statusCtx) + cancel() + ready.Store(err == nil && tailnetReady(status, tags)) + if err == nil && status != nil && !slices.Equal(status.TailscaleIPs, ips) { + return errors.New("tailnet addresses changed; recreating forwarding listeners") + } + } + } +} diff --git a/tunnel/tailscale_forward.go b/tunnel/tailscale_forward.go new file mode 100644 index 000000000..d74eda22b --- /dev/null +++ b/tunnel/tailscale_forward.go @@ -0,0 +1,253 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" +) + +type forwardPort struct{ network, port, target string } + +// The operator supplies only advertised ports and this exact Service. No client +// packet can choose a destination, and this node never advertises subnet routes. +func tailscaleForwardPlan(cfg Config) ([]forwardPort, error) { + if cfg.GameServerName == "" || cfg.GameServerNamespace == "" || cfg.BackingServiceDNS != cfg.GameServerName+"."+cfg.GameServerNamespace+".svc" { + return nil, errors.New("tailscale target must be the GameServer's backing Service DNS") + } + entries := strings.Split(cfg.BackingServicePorts, ",") + if len(entries) > 32 { + return nil, errors.New("tailscale supports at most 32 advertised ports") + } + ports := make([]forwardPort, 0, len(entries)) + seen := map[string]bool{} + for _, entry := range entries { + parts := strings.Split(entry, ":") + if len(parts) != 3 || parts[0] == "" { + return nil, fmt.Errorf("tailscale port %q must be name:port:tcp or name:port:udp; update the operator and tunnel images together", entry) + } + port, err := strconv.Atoi(parts[1]) + if err != nil || port < 1 || port > 65535 { + return nil, fmt.Errorf("invalid Tailscale port %q", entry) + } + if parts[2] != "tcp" && parts[2] != "udp" { + return nil, fmt.Errorf("unsupported Tailscale protocol %q; only TCP and UDP can be forwarded", parts[2]) + } + key := parts[2] + ":" + strconv.Itoa(port) + if seen[key] { + return nil, fmt.Errorf("duplicate Tailscale listener %s", key) + } + seen[key] = true + ports = append(ports, forwardPort{network: parts[2], port: strconv.Itoa(port), target: net.JoinHostPort(cfg.BackingServiceDNS, strconv.Itoa(port))}) + } + return ports, nil +} + +type forwardLimits struct { + sessions, udpQueue int + idle, dial time.Duration +} + +// The budget is shared by all listeners, protocols and address families. +// At most 128 sessions, four queued UDP packets each and one 64KiB response +// buffer per UDP session bound memory independent of client fan-out. +var defaultForwardLimits = forwardLimits{sessions: 128, udpQueue: 4, idle: 30 * time.Second, dial: 5 * time.Second} + +type forwarder struct { + ctx context.Context + cancel context.CancelFunc + limits forwardLimits + slots chan struct{} + wg sync.WaitGroup + listeners []io.Closer // registered before Close; no concurrent additions + closeOnce sync.Once + failed chan error +} + +func newForwarder(ctx context.Context, limits forwardLimits) *forwarder { + ctx, cancel := context.WithCancel(ctx) + return &forwarder{ctx: ctx, cancel: cancel, limits: limits, slots: make(chan struct{}, limits.sessions), failed: make(chan error, 1)} +} + +func (f *forwarder) Close() { + f.closeOnce.Do(func() { + f.cancel() + for _, listener := range f.listeners { + _ = listener.Close() + } + f.wg.Wait() + }) +} + +func (f *forwarder) fail(err error) { + if f.ctx.Err() == nil { + select { + case f.failed <- err: + default: + } + } +} + +func (f *forwarder) acquire() bool { + select { + case f.slots <- struct{}{}: + return true + default: + return false + } +} + +func (f *forwarder) serveTCP(listener net.Listener, target string) { + f.listeners = append(f.listeners, listener) + f.wg.Add(1) + go func() { + defer f.wg.Done() + for { + client, err := listener.Accept() + if err != nil { + f.fail(fmt.Errorf("TCP listener: %w", err)) + return + } + if !f.acquire() { + _ = client.Close() + continue + } + f.wg.Add(1) + go func() { defer f.wg.Done(); defer func() { <-f.slots }(); f.proxyTCP(client, target) }() + } + }() +} + +func (f *forwarder) proxyTCP(client net.Conn, target string) { + defer func() { _ = client.Close() }() + stopClient := context.AfterFunc(f.ctx, func() { _ = client.Close() }) + defer stopClient() + upstream, err := (&net.Dialer{Timeout: f.limits.dial}).DialContext(f.ctx, "tcp", target) + if err != nil { + return + } + defer func() { _ = upstream.Close() }() + stopUpstream := context.AfterFunc(f.ctx, func() { _ = upstream.Close() }) + defer stopUpstream() + // Activity in either direction refreshes each socket's idle timeout. + a := &idleConn{Conn: client, idle: 5 * time.Minute} + b := &idleConn{Conn: upstream, idle: 5 * time.Minute} + done := make(chan struct{}) + go func() { _, _ = io.CopyBuffer(b, a, make([]byte, 32*1024)); closeWrite(upstream); close(done) }() + _, _ = io.CopyBuffer(a, b, make([]byte, 32*1024)) + closeWrite(client) + _ = upstream.Close() + _ = client.Close() + <-done +} + +func closeWrite(conn net.Conn) { + if half, ok := conn.(interface{ CloseWrite() error }); ok { + _ = half.CloseWrite() + } +} + +type idleConn struct { + net.Conn + idle time.Duration +} + +func (c *idleConn) Read(p []byte) (int, error) { + _ = c.SetDeadline(time.Now().Add(c.idle)) + return c.Conn.Read(p) +} +func (c *idleConn) Write(p []byte) (int, error) { + _ = c.SetDeadline(time.Now().Add(c.idle)) + return c.Conn.Write(p) +} + +type udpForwardSession struct{ packets chan []byte } + +func (f *forwarder) serveUDP(listener net.PacketConn, target string) { + f.listeners = append(f.listeners, listener) + f.wg.Add(1) + go func() { + defer f.wg.Done() + var mu sync.Mutex + sessions := map[string]*udpForwardSession{} + buf := make([]byte, 65536) + for { + n, peer, err := listener.ReadFrom(buf) + if err != nil { + f.fail(fmt.Errorf("UDP listener: %w", err)) + return + } + if n > 65507 || f.ctx.Err() != nil { + continue + } + key := peer.String() + mu.Lock() + session := sessions[key] + if session == nil && f.acquire() { + session = &udpForwardSession{packets: make(chan []byte, f.limits.udpQueue)} + sessions[key] = session + f.wg.Add(1) + go func(session *udpForwardSession, peer net.Addr, key string) { + defer f.wg.Done() + defer func() { mu.Lock(); delete(sessions, key); mu.Unlock(); <-f.slots }() + f.proxyUDP(listener, peer, session.packets, target) + }(session, peer, key) + } + if session != nil { + // A slow backend or full queue drops the datagram; neither makes + // the receive loop wait, allocate workers, or grow memory. + if len(session.packets) < cap(session.packets) { + session.packets <- append([]byte(nil), buf[:n]...) + } + } + mu.Unlock() + } + }() +} + +func (f *forwarder) proxyUDP(listener net.PacketConn, peer net.Addr, packets <-chan []byte, target string) { + upstream, err := (&net.Dialer{Timeout: f.limits.dial}).DialContext(f.ctx, "udp", target) + if err != nil { + return + } + stop := context.AfterFunc(f.ctx, func() { _ = upstream.Close() }) + defer stop() + done := make(chan struct{}) + _ = upstream.SetReadDeadline(time.Now().Add(f.limits.idle)) + go func() { + defer close(done) + buf := make([]byte, 65536) + for { + n, err := upstream.Read(buf) + if err != nil { + return + } + if n <= 65507 { + _ = listener.SetWriteDeadline(time.Now().Add(f.limits.dial)) + if _, err := listener.WriteTo(buf[:n], peer); err != nil { + return + } + } + } + }() + defer func() { _ = upstream.Close(); <-done }() + for { + select { + case <-f.ctx.Done(): + return + case <-done: + return + case packet := <-packets: + _ = upstream.SetWriteDeadline(time.Now().Add(f.limits.dial)) + _ = upstream.SetReadDeadline(time.Now().Add(f.limits.idle)) + if _, err := upstream.Write(packet); err != nil { + return + } + } + } +} diff --git a/tunnel/tailscale_forward_test.go b/tunnel/tailscale_forward_test.go new file mode 100644 index 000000000..20376bbb2 --- /dev/null +++ b/tunnel/tailscale_forward_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "io" + "net" + "strconv" + "strings" + "testing" + "time" +) + +func TestTailscaleForwardPlanRestrictsDestinations(t *testing.T) { + cfg := Config{GameServerName: "game", GameServerNamespace: "games", BackingServiceDNS: "game.games.svc", BackingServicePorts: "java:25565:tcp,bedrock:19132:udp"} + ports, err := tailscaleForwardPlan(cfg) + if err != nil || len(ports) != 2 || ports[0].target != "game.games.svc:25565" || ports[1].network != "udp" { + t.Fatalf("plan: %+v, %v", ports, err) + } + for _, bad := range []string{"other.games.svc", "127.0.0.1", "game.games.svc:80", "http://game.games.svc"} { + cfg.BackingServiceDNS = bad + if _, err := tailscaleForwardPlan(cfg); err == nil { + t.Fatalf("accepted unrelated target %q", bad) + } + } + cfg.BackingServiceDNS = "game.games.svc" + for _, bad := range []string{"game:25565", "game:0:tcp", "game:65536:udp", "game:22:sctp", "game:22:tcp,other:22:tcp", strings.Repeat("p:1:tcp,", 33)} { + cfg.BackingServicePorts = bad + if _, err := tailscaleForwardPlan(cfg); err == nil { + t.Fatalf("accepted invalid ports %q", bad) + } + } +} + +func TestTailscaleTCPForwardAndShutdown(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + backend, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backend.Close() + go func() { + c, e := backend.Accept() + if e == nil { + defer c.Close() + _, _ = io.Copy(c, c) + } + }() + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + f := newForwarder(ctx, forwardLimits{sessions: 2, udpQueue: 2, idle: time.Second, dial: time.Second}) + f.serveTCP(listener, backend.Addr().String()) + defer f.Close() + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + _, _ = conn.Write([]byte("gameplay")) + buf := make([]byte, 8) + if _, err := io.ReadFull(conn, buf); err != nil || string(buf) != "gameplay" { + t.Fatalf("TCP reply %q: %v", buf, err) + } + f.Close() + if _, err := conn.Read(buf); err == nil { + t.Fatal("connection survived forwarding shutdown") + } +} + +func TestTailscaleUDPForwardIsolationLimitAndExpiry(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + backend, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backend.Close() + go func() { + buf := make([]byte, 65536) + for { + n, peer, e := backend.ReadFrom(buf) + if e != nil { + return + } + _, _ = backend.WriteTo(buf[:n], peer) + } + }() + listener, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + f := newForwarder(ctx, forwardLimits{sessions: 2, udpQueue: 2, idle: time.Second, dial: time.Second}) + f.serveUDP(listener, backend.LocalAddr().String()) + defer f.Close() + var clients []net.Conn + for i := 0; i < 3; i++ { + c, e := (&net.Dialer{}).DialContext(ctx, "udp", listener.LocalAddr().String()) + if e != nil { + t.Fatal(e) + } + defer c.Close() + clients = append(clients, c) + } + for i := 0; i < 2; i++ { + _ = clients[i].SetDeadline(time.Now().Add(time.Second)) + payload := "peer-" + strconv.Itoa(i) + _, _ = clients[i].Write([]byte(payload)) + buf := make([]byte, 32) + n, e := clients[i].Read(buf) + if e != nil || string(buf[:n]) != payload { + t.Fatalf("peer %d reply %q: %v", i, buf[:n], e) + } + } + _ = clients[2].SetDeadline(time.Now().Add(50 * time.Millisecond)) + _, _ = clients[2].Write([]byte("over limit")) + if _, err := clients[2].Read(make([]byte, 32)); err == nil { + t.Fatal("UDP session limit not enforced") + } + deadline := time.Now().Add(3 * time.Second) + for len(f.slots) != 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if len(f.slots) != 0 { + t.Fatal("idle UDP sessions were not reclaimed") + } + _ = clients[2].SetDeadline(time.Now().Add(time.Second)) + _, _ = clients[2].Write([]byte("new session")) + buf := make([]byte, 32) + n, err := clients[2].Read(buf) + if err != nil || string(buf[:n]) != "new session" { + t.Fatalf("session after expiry: %q %v", buf[:n], err) + } + f.Close() + if len(f.slots) != 0 { + t.Fatal("shutdown leaked sessions") + } +} diff --git a/tunnel/tailscale_test.go b/tunnel/tailscale_test.go new file mode 100644 index 000000000..d57d4ce6d --- /dev/null +++ b/tunnel/tailscale_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "sync" + "sync/atomic" + "testing" + "time" + + "tailscale.com/ipn/ipnstate" + "tailscale.com/types/views" +) + +func TestTailscaleServerPreservesIdentityAndTags(t *testing.T) { + cfg := Config{TailscaleHostname: "my-game", TailscaleTags: "game,tag:game,tag:friends"} + s, tags, err := tailscaleServer(cfg, "test-auth-key") + if err != nil { + t.Fatal(err) + } + if s.Hostname != "my-game" || s.AuthKey != "test-auth-key" || s.Dir != tailscaleStateDir || s.Ephemeral || len(tags) != 2 || !equalArgs(tags, s.AdvertiseTags) { + t.Fatalf("server lost identity or tags: %+v", tags) + } + cfg.TailscaleTags = "tag:bad tag" + if _, _, err := tailscaleServer(cfg, "key"); err == nil { + t.Fatal("invalid tags silently dropped") + } + cfg.TailscaleTags = "" + if _, _, err := tailscaleServer(cfg, ""); err == nil { + t.Fatal("empty auth key accepted") + } +} + +func onlineTailnet() *ipnstate.Status { + tags := views.SliceOf([]string{"tag:game"}) + return &ipnstate.Status{BackendState: "Running", TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}, Self: &ipnstate.PeerStatus{Online: true, Tags: &tags}} +} + +func TestTailscaleHostnameMustMatchAdvertisedEndpoint(t *testing.T) { + status := onlineTailnet() + status.Self.DNSName = "game.tailnet.ts.net." + if _, err := checkTailnetHostname(status, "game"); err != nil { + t.Fatal(err) + } + status.Self.DNSName = "game-1.tailnet.ts.net." + if _, err := checkTailnetHostname(status, "game"); err == nil { + t.Fatal("colliding hostname advertised as ready") + } +} + +type fakeTailnetNode struct { + mu sync.Mutex + status *ipnstate.Status + upErr error + listenErr bool + listened []string + listeners []net.Listener + packets []net.PacketConn +} + +func (f *fakeTailnetNode) Up(ctx context.Context) (*ipnstate.Status, error) { + if f.upErr != nil { + return nil, f.upErr + } + return f.Status(ctx) +} +func (f *fakeTailnetNode) Status(_ context.Context) (*ipnstate.Status, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.status, nil +} +func (f *fakeTailnetNode) Listen(network, addr string) (net.Listener, error) { + f.listened = append(f.listened, network+addr) + l, err := (&net.ListenConfig{}).Listen(context.Background(), network, "127.0.0.1:0") + if err == nil { + f.listeners = append(f.listeners, l) + } + return l, err +} +func (f *fakeTailnetNode) ListenPacket(network, addr string) (net.PacketConn, error) { + f.listened = append(f.listened, network+addr) + if f.listenErr { + return nil, errors.New("bind failed") + } + l, err := (&net.ListenConfig{}).ListenPacket(context.Background(), network, "127.0.0.1:0") + if err == nil { + f.packets = append(f.packets, l) + } + return l, err +} + +func awaitTailnetReady(t *testing.T, ready *atomic.Bool, want bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for ready.Load() != want && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if ready.Load() != want { + t.Fatalf("readiness=%v want %v", ready.Load(), want) + } +} + +func TestTailnetReadinessTracksAuthenticationListenersAndDisconnect(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + node := &fakeTailnetNode{status: onlineTailnet()} + var ready atomic.Bool + ports := []forwardPort{{network: "tcp", port: "25565", target: "127.0.0.1:1"}, {network: "udp", port: "19132", target: "127.0.0.1:2"}} + done := make(chan error, 1) + go func() { done <- serveTailnet(ctx, node, []string{"tag:game"}, ports, &ready, time.Millisecond) }() + awaitTailnetReady(t, &ready, true) + if len(node.listened) != 3 || node.listened[0] != "tcp:25565" || node.listened[1] != "udp100.64.0.1:19132" || node.listened[2] != "udp[fd7a:115c:a1e0::1]:19132" { + t.Fatalf("unexpected port/family exposure: %v", node.listened) + } + node.mu.Lock() + offline := onlineTailnet() + offline.Self.Online = false + node.status = offline + node.mu.Unlock() + awaitTailnetReady(t, &ready, false) + node.mu.Lock() + node.status = onlineTailnet() + node.mu.Unlock() + awaitTailnetReady(t, &ready, true) + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("shutdown stalled") + } + if ready.Load() { + t.Fatal("readiness survives shutdown") + } + for _, listener := range node.listeners { + if _, err := listener.Accept(); err == nil { + t.Fatal("listener survives shutdown") + } + } +} + +func TestTailnetFailuresNeverBecomeReady(t *testing.T) { + for _, scenario := range []string{"authentication", "denied tag", "listener"} { + t.Run(scenario, func(t *testing.T) { + node := &fakeTailnetNode{status: onlineTailnet()} + tags := []string{"tag:game"} + switch scenario { + case "authentication": + node.upErr = errors.New("unauthorized") + case "denied tag": + tags = []string{"tag:ungranted"} + case "listener": + node.listenErr = true + } + var ready atomic.Bool + err := serveTailnet(context.Background(), node, tags, []forwardPort{{network: "tcp", port: "1", target: "127.0.0.1:1"}, {network: "udp", port: "2", target: "127.0.0.1:2"}}, &ready, time.Millisecond) + if err == nil || ready.Load() { + t.Fatalf("failure became ready: %v", err) + } + for _, listener := range node.listeners { + if _, err := listener.Accept(); err == nil { + t.Fatal("partially opened listener leaked") + } + } + }) + } +} + +func TestTailnetListenerFailureClearsReady(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + node := &fakeTailnetNode{status: onlineTailnet()} + var ready atomic.Bool + done := make(chan error, 1) + go func() { + done <- serveTailnet(ctx, node, nil, []forwardPort{{network: "tcp", port: "25565", target: "127.0.0.1:1"}}, &ready, time.Millisecond) + }() + awaitTailnetReady(t, &ready, true) + _ = node.listeners[0].Close() + select { + case err := <-done: + if err == nil { + t.Fatal("failed listener was not reported") + } + case <-time.After(time.Second): + t.Fatal("failed listener did not stop forwarding") + } + if ready.Load() { + t.Fatal("failed forwarding listener still ready") + } +} + +func TestTailnetHealthNeedsForwardingReadiness(t *testing.T) { + var ready atomic.Bool + handler := tailscaleHealth(&ready) + for _, check := range []struct { + path string + ready bool + want int + }{{"/readyz", false, 503}, {"/healthz", false, 200}, {"/readyz", true, 200}, {"/readyz", false, 503}} { + ready.Store(check.ready) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, check.path, nil)) + if rec.Code != check.want { + t.Fatalf("%s=%d want%d", check.path, rec.Code, check.want) + } + } +}