Compare commits
4 Commits
docker_sta
...
v1.78.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80eb698d5 | ||
|
|
1aef3e83b8 | ||
|
|
2690b4762f | ||
|
|
0267fe83b2 |
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
|
||||
uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
|
||||
uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -80,4 +80,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
|
||||
uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
|
||||
|
||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
cache: false
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -319,7 +319,7 @@ jobs:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -367,7 +367,7 @@ jobs:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
|
||||
1
Makefile
1
Makefile
@@ -116,6 +116,7 @@ sshintegrationtest: ## Run the SSH integration tests in various Docker container
|
||||
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
|
||||
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.79.0
|
||||
1.78.1
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
type kubeClient struct {
|
||||
kubeclient.Client
|
||||
stateSecret string
|
||||
canPatch bool // whether the client has permissions to patch Kubernetes Secrets
|
||||
}
|
||||
|
||||
func newKubeClient(root string, stateSecret string) (*kubeClient, error) {
|
||||
|
||||
@@ -331,10 +331,8 @@ authLoop:
|
||||
if err := client.SetServeConfig(ctx, new(ipn.ServeConfig)); err != nil {
|
||||
log.Fatalf("failed to unset serve config: %v", err)
|
||||
}
|
||||
if hasKubeStateStore(cfg) {
|
||||
if err := kc.storeHTTPSEndpoint(ctx, ""); err != nil {
|
||||
log.Fatalf("failed to update HTTPS endpoint in tailscale state: %v", err)
|
||||
}
|
||||
if err := kc.storeHTTPSEndpoint(ctx, ""); err != nil {
|
||||
log.Fatalf("failed to update HTTPS endpoint in tailscale state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/netmap"
|
||||
@@ -58,16 +57,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("error unmarshaling tailscaled config: %v", err)
|
||||
}
|
||||
serveConf := ipn.ServeConfig{TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}}
|
||||
serveConfBytes, err := json.Marshal(serveConf)
|
||||
if err != nil {
|
||||
t.Fatalf("error unmarshaling serve config: %v", err)
|
||||
}
|
||||
egressSvcsCfg := egressservices.Configs{"foo": {TailnetTarget: egressservices.TailnetTarget{FQDN: "foo.tailnetxyx.ts.net"}}}
|
||||
egressSvcsCfgBytes, err := json.Marshal(egressSvcsCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("error unmarshaling egress services config: %v", err)
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
"var/lib",
|
||||
@@ -84,16 +73,14 @@ func TestContainerBoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
files := map[string][]byte{
|
||||
"usr/bin/tailscaled": fakeTailscaled,
|
||||
"usr/bin/tailscale": fakeTailscale,
|
||||
"usr/bin/iptables": fakeTailscale,
|
||||
"usr/bin/ip6tables": fakeTailscale,
|
||||
"dev/net/tun": []byte(""),
|
||||
"proc/sys/net/ipv4/ip_forward": []byte("0"),
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": []byte("0"),
|
||||
"etc/tailscaled/cap-95.hujson": tailscaledConfBytes,
|
||||
"etc/tailscaled/serve-config.json": serveConfBytes,
|
||||
"etc/tailscaled/egress-services-config.json": egressSvcsCfgBytes,
|
||||
"usr/bin/tailscaled": fakeTailscaled,
|
||||
"usr/bin/tailscale": fakeTailscale,
|
||||
"usr/bin/iptables": fakeTailscale,
|
||||
"usr/bin/ip6tables": fakeTailscale,
|
||||
"dev/net/tun": []byte(""),
|
||||
"proc/sys/net/ipv4/ip_forward": []byte("0"),
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": []byte("0"),
|
||||
"etc/tailscaled/cap-95.hujson": tailscaledConfBytes,
|
||||
}
|
||||
resetFiles := func() {
|
||||
for path, content := range files {
|
||||
@@ -842,101 +829,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "serve_config_no_kube",
|
||||
Env: map[string]string{
|
||||
"TS_SERVE_CONFIG": filepath.Join(d, "etc/tailscaled/serve-config.json"),
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "serve_config_kube",
|
||||
Env: map[string]string{
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
"TS_SERVE_CONFIG": filepath.Join(d, "etc/tailscaled/serve-config.json"),
|
||||
},
|
||||
KubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
"https_endpoint": "no-https",
|
||||
"tailscale_capver": capver,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "egress_svcs_config_kube",
|
||||
Env: map[string]string{
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
"TS_EGRESS_SERVICES_CONFIG_PATH": filepath.Join(d, "etc/tailscaled/egress-services-config.json"),
|
||||
},
|
||||
KubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
"tailscale_capver": capver,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "egress_svcs_config_no_kube",
|
||||
Env: map[string]string{
|
||||
"TS_EGRESS_SERVICES_CONFIG_PATH": filepath.Join(d, "etc/tailscaled/egress-services-config.json"),
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantFatalLog: "TS_EGRESS_SERVICES_CONFIG_PATH is only supported for Tailscale running on Kubernetes",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -72,10 +72,8 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
|
||||
if err := updateServeConfig(ctx, sc, certDomain, lc); err != nil {
|
||||
log.Fatalf("serve proxy: error updating serve config: %v", err)
|
||||
}
|
||||
if kc != nil && kc.canPatch {
|
||||
if err := kc.storeHTTPSEndpoint(ctx, certDomain); err != nil {
|
||||
log.Fatalf("serve proxy: error storing HTTPS endpoint: %v", err)
|
||||
}
|
||||
if err := kc.storeHTTPSEndpoint(ctx, certDomain); err != nil {
|
||||
log.Fatalf("serve proxy: error storing HTTPS endpoint: %v", err)
|
||||
}
|
||||
prevServeConfig = sc
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ type settings struct {
|
||||
DaemonExtraArgs string
|
||||
ExtraArgs string
|
||||
InKubernetes bool
|
||||
State string
|
||||
UserspaceMode bool
|
||||
StateDir string
|
||||
AcceptDNS *bool
|
||||
@@ -90,7 +89,6 @@ func configFromEnv() (*settings, error) {
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
State: defaultEnv("TS_STATE", ""),
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
|
||||
@@ -112,19 +110,6 @@ func configFromEnv() (*settings, error) {
|
||||
EgressSvcsCfgPath: defaultEnv("TS_EGRESS_SERVICES_CONFIG_PATH", ""),
|
||||
PodUID: defaultEnv("POD_UID", ""),
|
||||
}
|
||||
|
||||
if cfg.State == "" {
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" {
|
||||
cfg.State = "kube:" + cfg.KubeSecret
|
||||
} else {
|
||||
cfg.State = "mem:"
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.State, "mem:") && !strings.HasPrefix(cfg.State, "kube:") && !strings.HasPrefix(cfg.State, "ssm:") {
|
||||
return nil, fmt.Errorf("invalid TS_STATE value %q; must start with 'mem:', 'kube:', or 'ssm:'", cfg.State)
|
||||
}
|
||||
|
||||
podIPs, ok := os.LookupEnv("POD_IPS")
|
||||
if ok {
|
||||
ips := strings.Split(podIPs, ",")
|
||||
@@ -150,35 +135,6 @@ func configFromEnv() (*settings, error) {
|
||||
}
|
||||
|
||||
func (s *settings) validate() error {
|
||||
|
||||
// Validate TS_STATE if set
|
||||
if s.State != "" {
|
||||
if !strings.HasPrefix(s.State, "mem:") &&
|
||||
!strings.HasPrefix(s.State, "kube:") &&
|
||||
!strings.HasPrefix(s.State, "ssm:") {
|
||||
return fmt.Errorf("invalid TS_STATE value %q; must start with 'mem:', 'kube:', or 'ssm:'", s.State)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s.State, "kube:") && !s.InKubernetes {
|
||||
return fmt.Errorf("TS_STATE specifies Kubernetes state but the runtime environment is not Kubernetes")
|
||||
}
|
||||
}
|
||||
|
||||
// Check legacy settings and ensure no conflicts if TS_STATE is set
|
||||
if s.State != "" {
|
||||
if s.KubeSecret != "" {
|
||||
log.Printf("[warning] TS_STATE is set; ignoring legacy TS_KUBE_SECRET")
|
||||
}
|
||||
if s.StateDir != "" {
|
||||
log.Printf("[warning] TS_STATE is set; ignoring legacy TS_STATE_DIR")
|
||||
}
|
||||
} else {
|
||||
// Fallback to legacy checks if TS_STATE is not set
|
||||
if s.KubeSecret != "" && !s.InKubernetes {
|
||||
return fmt.Errorf("TS_KUBE_SECRET is set but the runtime environment is not Kubernetes")
|
||||
}
|
||||
}
|
||||
|
||||
if s.TailscaledConfigFilePath != "" {
|
||||
dir, file := path.Split(s.TailscaledConfigFilePath)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
@@ -243,10 +199,6 @@ func (s *settings) validate() error {
|
||||
if s.HealthCheckEnabled && s.HealthCheckAddrPort != "" {
|
||||
return errors.New("TS_HEALTHCHECK_ADDR_PORT is deprecated and will be removed in 1.82.0, use TS_ENABLE_HEALTH_CHECK and optionally TS_LOCAL_ADDR_PORT")
|
||||
}
|
||||
if s.EgressSvcsCfgPath != "" && !(s.InKubernetes && s.KubeSecret != "") {
|
||||
return errors.New("TS_EGRESS_SERVICES_CONFIG_PATH is only supported for Tailscale running on Kubernetes")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -262,7 +214,6 @@ func (cfg *settings) setupKube(ctx context.Context, kc *kubeClient) error {
|
||||
return fmt.Errorf("some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
|
||||
}
|
||||
cfg.KubernetesCanPatch = canPatch
|
||||
kc.canPatch = canPatch
|
||||
|
||||
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
|
||||
@@ -62,22 +62,17 @@ func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient
|
||||
// tailscaledArgs uses cfg to construct the argv for tailscaled.
|
||||
func tailscaledArgs(cfg *settings) []string {
|
||||
args := []string{"--socket=" + cfg.Socket}
|
||||
if cfg.State != "" {
|
||||
args = append(args, "--state="+cfg.State)
|
||||
} else {
|
||||
// Fallback logic for legacy state configuration
|
||||
switch {
|
||||
case cfg.InKubernetes && cfg.KubeSecret != "":
|
||||
args = append(args, "--state=kube:"+cfg.KubeSecret)
|
||||
if cfg.StateDir == "" {
|
||||
cfg.StateDir = "/tmp"
|
||||
}
|
||||
fallthrough
|
||||
case cfg.StateDir != "":
|
||||
args = append(args, "--statedir="+cfg.StateDir)
|
||||
default:
|
||||
args = append(args, "--state=mem:", "--statedir=/tmp")
|
||||
switch {
|
||||
case cfg.InKubernetes && cfg.KubeSecret != "":
|
||||
args = append(args, "--state=kube:"+cfg.KubeSecret)
|
||||
if cfg.StateDir == "" {
|
||||
cfg.StateDir = "/tmp"
|
||||
}
|
||||
fallthrough
|
||||
case cfg.StateDir != "":
|
||||
args = append(args, "--statedir="+cfg.StateDir)
|
||||
default:
|
||||
args = append(args, "--state=mem:", "--statedir=/tmp")
|
||||
}
|
||||
|
||||
if cfg.UserspaceMode {
|
||||
|
||||
@@ -46,6 +46,7 @@ func main() {
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
TokenURL: baseURL + "/api/v2/oauth/token",
|
||||
Scopes: []string{"device"},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -58,8 +58,8 @@ func apply(cache *Cache, client *http.Client, tailnet, apiKey string) func(conte
|
||||
}
|
||||
|
||||
if cache.PrevETag == "" {
|
||||
log.Println("no previous etag found, assuming the latest control etag")
|
||||
cache.PrevETag = controlEtag
|
||||
log.Println("no previous etag found, assuming local file is correct and recording that")
|
||||
cache.PrevETag = localEtag
|
||||
}
|
||||
|
||||
log.Printf("control: %s", controlEtag)
|
||||
@@ -105,8 +105,8 @@ func test(cache *Cache, client *http.Client, tailnet, apiKey string) func(contex
|
||||
}
|
||||
|
||||
if cache.PrevETag == "" {
|
||||
log.Println("no previous etag found, assuming the latest control etag")
|
||||
cache.PrevETag = controlEtag
|
||||
log.Println("no previous etag found, assuming local file is correct and recording that")
|
||||
cache.PrevETag = localEtag
|
||||
}
|
||||
|
||||
log.Printf("control: %s", controlEtag)
|
||||
@@ -148,8 +148,8 @@ func getChecksums(cache *Cache, client *http.Client, tailnet, apiKey string) fun
|
||||
}
|
||||
|
||||
if cache.PrevETag == "" {
|
||||
log.Println("no previous etag found, assuming control etag")
|
||||
cache.PrevETag = Shuck(controlEtag)
|
||||
log.Println("no previous etag found, assuming local file is correct and recording that")
|
||||
cache.PrevETag = Shuck(localEtag)
|
||||
}
|
||||
|
||||
log.Printf("control: %s", controlEtag)
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/config"
|
||||
kube "tailscale.com/k8s-operator"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
// See [TestMain] for test requirements.
|
||||
func TestIngress(t *testing.T) {
|
||||
if tsClient == nil {
|
||||
t.Skip("TestIngress requires credentials for a tailscale client")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := config.GetConfigOrDie()
|
||||
cl, err := client.New(cfg, client.Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Apply nginx
|
||||
createAndCleanup(t, ctx, cl, &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "nginx",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{
|
||||
"app.kubernetes.io/name": "nginx",
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "nginx",
|
||||
Image: "nginx",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Apply service to expose it as ingress
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-ingress",
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{
|
||||
"app.kubernetes.io/name": "nginx",
|
||||
},
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
Name: "http",
|
||||
Protocol: "TCP",
|
||||
Port: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
createAndCleanup(t, ctx, cl, svc)
|
||||
|
||||
// TODO: instead of timing out only when test times out, cancel context after 60s or so.
|
||||
if err := wait.PollUntilContextCancel(ctx, time.Millisecond*100, true, func(ctx context.Context) (done bool, err error) {
|
||||
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta("default", "test-ingress")}
|
||||
if err := get(ctx, cl, maybeReadySvc); err != nil {
|
||||
return false, err
|
||||
}
|
||||
isReady := kube.SvcIsReady(maybeReadySvc)
|
||||
if isReady {
|
||||
t.Log("Service is ready")
|
||||
}
|
||||
return isReady, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("error waiting for the Service to become Ready: %v", err)
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
if err := tstest.WaitFor(time.Second*60, func() error {
|
||||
// TODO(tomhjp): Get the tailnet DNS name from the associated secret instead.
|
||||
// If we are not the first tailnet node with the requested name, we'll get
|
||||
// a -N suffix.
|
||||
resp, err = tsClient.HTTPClient.Get(fmt.Sprintf("http://%s-%s:80", svc.Namespace, svc.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("error trying to reach service: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %v; response body s", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-logr/zapr"
|
||||
"github.com/tailscale/hujson"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||
kzap "sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
const (
|
||||
e2eManagedComment = "// This is managed by the k8s-operator e2e tests"
|
||||
)
|
||||
|
||||
var (
|
||||
tsClient *tailscale.Client
|
||||
testGrants = map[string]string{
|
||||
"test-proxy": `{
|
||||
"src": ["tag:e2e-test-proxy"],
|
||||
"dst": ["tag:k8s-operator"],
|
||||
"app": {
|
||||
"tailscale.com/cap/kubernetes": [{
|
||||
"impersonate": {
|
||||
"groups": ["ts:e2e-test-proxy"],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}`,
|
||||
}
|
||||
)
|
||||
|
||||
// This test suite is currently not run in CI.
|
||||
// It requires some setup not handled by this code:
|
||||
// - Kubernetes cluster with tailscale operator installed
|
||||
// - Current kubeconfig context set to connect to that cluster (directly, no operator proxy)
|
||||
// - Operator installed with --set apiServerProxyConfig.mode="true"
|
||||
// - ACLs that define tag:e2e-test-proxy tag. TODO(tomhjp): Can maybe replace this prereq onwards with an API key
|
||||
// - OAuth client ID and secret in TS_API_CLIENT_ID and TS_API_CLIENT_SECRET env
|
||||
// - OAuth client must have auth_keys and policy_file write for tag:e2e-test-proxy tag
|
||||
func TestMain(m *testing.M) {
|
||||
code, err := runTests(m)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func runTests(m *testing.M) (int, error) {
|
||||
zlog := kzap.NewRaw([]kzap.Opts{kzap.UseDevMode(true), kzap.Level(zapcore.DebugLevel)}...).Sugar()
|
||||
logf.SetLogger(zapr.NewLogger(zlog.Desugar()))
|
||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
||||
|
||||
if clientID := os.Getenv("TS_API_CLIENT_ID"); clientID != "" {
|
||||
cleanup, err := setupClientAndACLs()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, cleanup())
|
||||
}()
|
||||
}
|
||||
|
||||
return m.Run(), nil
|
||||
}
|
||||
|
||||
func setupClientAndACLs() (cleanup func() error, _ error) {
|
||||
ctx := context.Background()
|
||||
credentials := clientcredentials.Config{
|
||||
ClientID: os.Getenv("TS_API_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("TS_API_CLIENT_SECRET"),
|
||||
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
|
||||
Scopes: []string{"auth_keys", "policy_file"},
|
||||
}
|
||||
tsClient = tailscale.NewClient("-", nil)
|
||||
tsClient.HTTPClient = credentials.Client(ctx)
|
||||
|
||||
if err := patchACLs(ctx, tsClient, func(acls *hujson.Value) {
|
||||
for test, grant := range testGrants {
|
||||
deleteTestGrants(test, acls)
|
||||
addTestGrant(test, grant, acls)
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() error {
|
||||
return patchACLs(ctx, tsClient, func(acls *hujson.Value) {
|
||||
for test := range testGrants {
|
||||
deleteTestGrants(test, acls)
|
||||
}
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
func patchACLs(ctx context.Context, tsClient *tailscale.Client, patchFn func(*hujson.Value)) error {
|
||||
acls, err := tsClient.ACLHuJSON(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hj, err := hujson.Parse([]byte(acls.ACL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
patchFn(&hj)
|
||||
|
||||
hj.Format()
|
||||
acls.ACL = hj.String()
|
||||
if _, err := tsClient.SetACLHuJSON(ctx, *acls, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addTestGrant(test, grant string, acls *hujson.Value) error {
|
||||
v, err := hujson.Parse([]byte(grant))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add the managed comment to the first line of the grant object contents.
|
||||
v.Value.(*hujson.Object).Members[0].Name.BeforeExtra = hujson.Extra(fmt.Sprintf("%s: %s\n", e2eManagedComment, test))
|
||||
|
||||
if err := acls.Patch([]byte(fmt.Sprintf(`[{"op": "add", "path": "/grants/-", "value": %s}]`, v.String()))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteTestGrants(test string, acls *hujson.Value) error {
|
||||
grants := acls.Find("/grants")
|
||||
|
||||
var patches []string
|
||||
for i, g := range grants.Value.(*hujson.Array).Elements {
|
||||
members := g.Value.(*hujson.Object).Members
|
||||
if len(members) == 0 {
|
||||
continue
|
||||
}
|
||||
comment := strings.TrimSpace(string(members[0].Name.BeforeExtra))
|
||||
if name, found := strings.CutPrefix(comment, e2eManagedComment+": "); found && name == test {
|
||||
patches = append(patches, fmt.Sprintf(`{"op": "remove", "path": "/grants/%d"}`, i))
|
||||
}
|
||||
}
|
||||
|
||||
// Remove in reverse order so we don't affect the found indices as we mutate.
|
||||
slices.Reverse(patches)
|
||||
|
||||
if err := acls.Patch([]byte(fmt.Sprintf("[%s]", strings.Join(patches, ",")))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func objectMeta(namespace, name string) metav1.ObjectMeta {
|
||||
return metav1.ObjectMeta{
|
||||
Namespace: namespace,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func createAndCleanup(t *testing.T, ctx context.Context, cl client.Client, obj client.Object) {
|
||||
t.Helper()
|
||||
if err := cl.Create(ctx, obj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cl.Delete(ctx, obj); err != nil {
|
||||
t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func get(ctx context.Context, cl client.Client, obj client.Object) error {
|
||||
return cl.Get(ctx, client.ObjectKeyFromObject(obj), obj)
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/client-go/rest"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/config"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
// See [TestMain] for test requirements.
|
||||
func TestProxy(t *testing.T) {
|
||||
if tsClient == nil {
|
||||
t.Skip("TestProxy requires credentials for a tailscale client")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := config.GetConfigOrDie()
|
||||
cl, err := client.New(cfg, client.Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create role and role binding to allow a group we'll impersonate to do stuff.
|
||||
createAndCleanup(t, ctx, cl, &rbacv1.Role{
|
||||
ObjectMeta: objectMeta("tailscale", "read-secrets"),
|
||||
Rules: []rbacv1.PolicyRule{{
|
||||
APIGroups: []string{""},
|
||||
Verbs: []string{"get"},
|
||||
Resources: []string{"secrets"},
|
||||
}},
|
||||
})
|
||||
createAndCleanup(t, ctx, cl, &rbacv1.RoleBinding{
|
||||
ObjectMeta: objectMeta("tailscale", "read-secrets"),
|
||||
Subjects: []rbacv1.Subject{{
|
||||
Kind: "Group",
|
||||
Name: "ts:e2e-test-proxy",
|
||||
}},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
Kind: "Role",
|
||||
Name: "read-secrets",
|
||||
},
|
||||
})
|
||||
|
||||
// Get operator host name from kube secret.
|
||||
operatorSecret := corev1.Secret{
|
||||
ObjectMeta: objectMeta("tailscale", "operator"),
|
||||
}
|
||||
if err := get(ctx, cl, &operatorSecret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Connect to tailnet with test-specific tag so we can use the
|
||||
// [testGrants] ACLs when connecting to the API server proxy
|
||||
ts := tsnetServerWithTag(t, ctx, "tag:e2e-test-proxy")
|
||||
proxyCfg := &rest.Config{
|
||||
Host: fmt.Sprintf("https://%s:443", hostNameFromOperatorSecret(t, operatorSecret)),
|
||||
Dial: ts.Dial,
|
||||
}
|
||||
proxyCl, err := client.New(proxyCfg, client.Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Expect success.
|
||||
allowedSecret := corev1.Secret{
|
||||
ObjectMeta: objectMeta("tailscale", "operator"),
|
||||
}
|
||||
// Wait for up to a minute the first time we use the proxy, to give it time
|
||||
// to provision the TLS certs.
|
||||
if err := tstest.WaitFor(time.Second*60, func() error {
|
||||
return get(ctx, proxyCl, &allowedSecret)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Expect forbidden.
|
||||
forbiddenSecret := corev1.Secret{
|
||||
ObjectMeta: objectMeta("default", "operator"),
|
||||
}
|
||||
if err := get(ctx, proxyCl, &forbiddenSecret); err == nil || !apierrors.IsForbidden(err) {
|
||||
t.Fatalf("expected forbidden error fetching secret from default namespace: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func tsnetServerWithTag(t *testing.T, ctx context.Context, tag string) *tsnet.Server {
|
||||
caps := tailscale.KeyCapabilities{
|
||||
Devices: tailscale.KeyDeviceCapabilities{
|
||||
Create: tailscale.KeyDeviceCreateCapabilities{
|
||||
Reusable: false,
|
||||
Preauthorized: true,
|
||||
Ephemeral: true,
|
||||
Tags: []string{tag},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
authKey, authKeyMeta, err := tsClient.CreateKey(ctx, caps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := tsClient.DeleteKey(ctx, authKeyMeta.ID); err != nil {
|
||||
t.Errorf("error deleting auth key: %s", err)
|
||||
}
|
||||
})
|
||||
|
||||
ts := &tsnet.Server{
|
||||
Hostname: "test-proxy",
|
||||
Ephemeral: true,
|
||||
Dir: t.TempDir(),
|
||||
AuthKey: authKey,
|
||||
}
|
||||
_, err = ts.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := ts.Close(); err != nil {
|
||||
t.Errorf("error shutting down tsnet.Server: %s", err)
|
||||
}
|
||||
})
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
func hostNameFromOperatorSecret(t *testing.T, s corev1.Secret) string {
|
||||
profiles := map[string]any{}
|
||||
if err := json.Unmarshal(s.Data["_profiles"], &profiles); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, ok := strings.CutPrefix(string(s.Data["_current-profile"]), "profile-")
|
||||
if !ok {
|
||||
t.Fatal(string(s.Data["_current-profile"]))
|
||||
}
|
||||
profile, ok := profiles[key]
|
||||
if !ok {
|
||||
t.Fatal(profiles)
|
||||
}
|
||||
|
||||
return ((profile.(map[string]any))["Name"]).(string)
|
||||
}
|
||||
@@ -5,40 +5,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"tailscale.com/net/stun"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
var host string
|
||||
|
||||
if len(os.Args) < 2 || len(os.Args) > 3 {
|
||||
log.Fatalf("usage: %s <hostname> [port]", os.Args[0])
|
||||
}
|
||||
host := os.Args[1]
|
||||
port := "3478"
|
||||
|
||||
var readTimeout time.Duration
|
||||
flag.DurationVar(&readTimeout, "timeout", 3*time.Second, "response wait timeout")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
values := flag.Args()
|
||||
if len(values) < 1 || len(values) > 2 {
|
||||
log.Printf("usage: %s <hostname> [port]", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
} else {
|
||||
for i, value := range values {
|
||||
switch i {
|
||||
case 0:
|
||||
host = value
|
||||
case 1:
|
||||
port = value
|
||||
}
|
||||
}
|
||||
if len(os.Args) == 3 {
|
||||
port = os.Args[2]
|
||||
}
|
||||
_, err := strconv.ParseUint(port, 10, 16)
|
||||
if err != nil {
|
||||
@@ -62,10 +46,6 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = c.SetReadDeadline(time.Now().Add(readTimeout))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var buf [1024]byte
|
||||
n, raddr, err := c.ReadFromUDPAddrPort(buf[:])
|
||||
if err != nil {
|
||||
|
||||
@@ -1157,6 +1157,7 @@ func resolveAuthKey(ctx context.Context, v, tags string) (string, error) {
|
||||
ClientID: "some-client-id", // ignored
|
||||
ClientSecret: clientSecret,
|
||||
TokenURL: baseURL + "/api/v2/oauth/token",
|
||||
Scopes: []string{"device"},
|
||||
}
|
||||
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
|
||||
@@ -84,19 +84,11 @@ func init() {
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPerClientSendQueueDepth = 32 // default packets buffered for sending
|
||||
writeTimeout = 2 * time.Second
|
||||
privilegedWriteTimeout = 30 * time.Second // for clients with the mesh key
|
||||
perClientSendQueueDepth = 32 // packets buffered for sending
|
||||
writeTimeout = 2 * time.Second
|
||||
privilegedWriteTimeout = 30 * time.Second // for clients with the mesh key
|
||||
)
|
||||
|
||||
func getPerClientSendQueueDepth() int {
|
||||
if v, ok := envknob.LookupInt("TS_DEBUG_DERP_PER_CLIENT_SEND_QUEUE_DEPTH"); ok {
|
||||
return v
|
||||
}
|
||||
|
||||
return defaultPerClientSendQueueDepth
|
||||
}
|
||||
|
||||
// dupPolicy is a temporary (2021-08-30) mechanism to change the policy
|
||||
// of how duplicate connection for the same key are handled.
|
||||
type dupPolicy int8
|
||||
@@ -198,9 +190,6 @@ type Server struct {
|
||||
// maps from netip.AddrPort to a client's public key
|
||||
keyOfAddr map[netip.AddrPort]key.NodePublic
|
||||
|
||||
// Sets the client send queue depth for the server.
|
||||
perClientSendQueueDepth int
|
||||
|
||||
clock tstime.Clock
|
||||
}
|
||||
|
||||
@@ -388,8 +377,6 @@ func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
|
||||
|
||||
s.packetsDroppedTypeDisco = s.packetsDroppedType.Get("disco")
|
||||
s.packetsDroppedTypeOther = s.packetsDroppedType.Get("other")
|
||||
|
||||
s.perClientSendQueueDepth = getPerClientSendQueueDepth()
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -862,8 +849,8 @@ func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, rem
|
||||
done: ctx.Done(),
|
||||
remoteIPPort: remoteIPPort,
|
||||
connectedAt: s.clock.Now(),
|
||||
sendQueue: make(chan pkt, s.perClientSendQueueDepth),
|
||||
discoSendQueue: make(chan pkt, s.perClientSendQueueDepth),
|
||||
sendQueue: make(chan pkt, perClientSendQueueDepth),
|
||||
discoSendQueue: make(chan pkt, perClientSendQueueDepth),
|
||||
sendPongCh: make(chan [8]byte, 1),
|
||||
peerGone: make(chan peerGoneMsg),
|
||||
canMesh: s.isMeshPeer(clientInfo),
|
||||
|
||||
@@ -6,7 +6,6 @@ package derp
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
@@ -24,7 +23,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qt "github.com/frankban/quicktest"
|
||||
"go4.org/mem"
|
||||
"golang.org/x/time/rate"
|
||||
"tailscale.com/disco"
|
||||
@@ -1600,29 +1598,3 @@ func TestServerRepliesToPing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPerClientSendQueueDepth(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
envKey := "TS_DEBUG_DERP_PER_CLIENT_SEND_QUEUE_DEPTH"
|
||||
|
||||
testCases := []struct {
|
||||
envVal string
|
||||
want int
|
||||
}{
|
||||
// Empty case, envknob treats empty as missing also.
|
||||
{
|
||||
"", defaultPerClientSendQueueDepth,
|
||||
},
|
||||
{
|
||||
"64", 64,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(cmp.Or(tc.envVal, "empty"), func(t *testing.T) {
|
||||
t.Setenv(envKey, tc.envVal)
|
||||
val := getPerClientSendQueueDepth()
|
||||
c.Assert(val, qt.Equals, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,9 +757,6 @@ func (c *Client) dialNode(ctx context.Context, n *tailcfg.DERPNode) (net.Conn, e
|
||||
}
|
||||
dst := cmp.Or(dstPrimary, n.HostName)
|
||||
port := "443"
|
||||
if !c.useHTTPS() {
|
||||
port = "3340"
|
||||
}
|
||||
if n.DERPPort != 0 {
|
||||
port = fmt.Sprint(n.DERPPort)
|
||||
}
|
||||
|
||||
12
go.mod
12
go.mod
@@ -95,14 +95,14 @@ require (
|
||||
go.uber.org/zap v1.27.0
|
||||
go4.org/mem v0.0.0-20220726221520-4f986261bf13
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||
golang.org/x/crypto v0.30.0
|
||||
golang.org/x/crypto v0.25.0
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a
|
||||
golang.org/x/mod v0.19.0
|
||||
golang.org/x/net v0.32.0
|
||||
golang.org/x/net v0.27.0
|
||||
golang.org/x/oauth2 v0.16.0
|
||||
golang.org/x/sync v0.10.0
|
||||
golang.org/x/sys v0.28.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/sync v0.9.0
|
||||
golang.org/x/sys v0.27.0
|
||||
golang.org/x/term v0.22.0
|
||||
golang.org/x/time v0.5.0
|
||||
golang.org/x/tools v0.23.0
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2
|
||||
@@ -386,7 +386,7 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
|
||||
24
go.sum
24
go.sum
@@ -1062,8 +1062,8 @@ golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
|
||||
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -1153,8 +1153,8 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1176,8 +1176,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1239,8 +1239,8 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -1248,8 +1248,8 @@ golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
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.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
|
||||
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1262,8 +1262,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
||||
@@ -35,12 +35,8 @@ remotes/origin/QTSFW_5.0.0`
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageTypeNotContainer(t *testing.T) {
|
||||
var got string
|
||||
if packageType != nil {
|
||||
got = packageType()
|
||||
}
|
||||
if got == "container" {
|
||||
t.Fatal("packageType = container; should only happen if build tag ts_package_container is set")
|
||||
func TestInContainer(t *testing.T) {
|
||||
if got := inContainer(); !got.EqualBool(false) {
|
||||
t.Errorf("inContainer = %v; want false due to absence of ts_package_container build tag", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +167,3 @@ func DNSCfgIsReady(cfg *tsapi.DNSConfig) bool {
|
||||
cond := cfg.Status.Conditions[idx]
|
||||
return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == cfg.Generation
|
||||
}
|
||||
|
||||
func SvcIsReady(svc *corev1.Service) bool {
|
||||
idx := xslices.IndexFunc(svc.Status.Conditions, func(cond metav1.Condition) bool {
|
||||
return cond.Type == string(tsapi.ProxyReady)
|
||||
})
|
||||
if idx == -1 {
|
||||
return false
|
||||
}
|
||||
cond := svc.Status.Conditions[idx]
|
||||
return cond.Status == metav1.ConditionTrue
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ package logpolicy
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
@@ -450,63 +449,25 @@ func tryFixLogStateLocation(dir, cmdname string, logf logger.Logf) {
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: Use [Options.New] instead.
|
||||
// New returns a new log policy (a logger and its instance ID) for a given
|
||||
// collection name.
|
||||
//
|
||||
// The netMon parameter is optional. It should be specified in environments where
|
||||
// Tailscaled is manipulating the routing table.
|
||||
//
|
||||
// The logf parameter is optional; if non-nil, information logs (e.g. when
|
||||
// migrating state) are sent to that logger, and global changes to the log
|
||||
// package are avoided. If nil, logs will be printed using log.Printf.
|
||||
func New(collection string, netMon *netmon.Monitor, health *health.Tracker, logf logger.Logf) *Policy {
|
||||
return Options{
|
||||
Collection: collection,
|
||||
NetMon: netMon,
|
||||
Health: health,
|
||||
Logf: logf,
|
||||
}.New()
|
||||
return NewWithConfigPath(collection, "", "", netMon, health, logf)
|
||||
}
|
||||
|
||||
// Deprecated: Use [Options.New] instead.
|
||||
// NewWithConfigPath is identical to New, but uses the specified directory and
|
||||
// command name. If either is empty, it derives them automatically.
|
||||
//
|
||||
// The netMon parameter is optional. It should be specified in environments where
|
||||
// Tailscaled is manipulating the routing table.
|
||||
func NewWithConfigPath(collection, dir, cmdName string, netMon *netmon.Monitor, health *health.Tracker, logf logger.Logf) *Policy {
|
||||
return Options{
|
||||
Collection: collection,
|
||||
Dir: dir,
|
||||
CmdName: cmdName,
|
||||
NetMon: netMon,
|
||||
Health: health,
|
||||
Logf: logf,
|
||||
}.New()
|
||||
}
|
||||
|
||||
// Options is used to construct a [Policy].
|
||||
type Options struct {
|
||||
// Collection is a required collection to upload logs under.
|
||||
// Collection is a namespace for the type logs.
|
||||
// For example, logs for a node use "tailnode.log.tailscale.io".
|
||||
Collection string
|
||||
|
||||
// Dir is an optional directory to store the log configuration.
|
||||
// If empty, [LogsDir] is used.
|
||||
Dir string
|
||||
|
||||
// CmdName is an optional name of the current binary.
|
||||
// If empty, [version.CmdName] is used.
|
||||
CmdName string
|
||||
|
||||
// NetMon is an optional parameter for monitoring.
|
||||
// If non-nil, it's used to do faster interface lookups.
|
||||
NetMon *netmon.Monitor
|
||||
|
||||
// Health is an optional parameter for health status.
|
||||
// If non-nil, it's used to construct the default HTTP client.
|
||||
Health *health.Tracker
|
||||
|
||||
// Logf is an optional logger to use.
|
||||
// If nil, [log.Printf] will be used instead.
|
||||
Logf logger.Logf
|
||||
|
||||
// HTTPC is an optional client to use upload logs.
|
||||
// If nil, [TransportOptions.New] is used to construct a new client
|
||||
// with that particular transport sending logs to the default logs server.
|
||||
HTTPC *http.Client
|
||||
}
|
||||
|
||||
// New returns a new log policy (a logger and its instance ID).
|
||||
func (opts Options) New() *Policy {
|
||||
if hostinfo.IsNATLabGuestVM() {
|
||||
// In NATLab Gokrazy instances, tailscaled comes up concurently with
|
||||
// DHCP and the doesn't have DNS for a while. Wait for DHCP first.
|
||||
@@ -534,23 +495,23 @@ func (opts Options) New() *Policy {
|
||||
earlyErrBuf.WriteByte('\n')
|
||||
}
|
||||
|
||||
if opts.Dir == "" {
|
||||
opts.Dir = LogsDir(earlyLogf)
|
||||
if dir == "" {
|
||||
dir = LogsDir(earlyLogf)
|
||||
}
|
||||
if opts.CmdName == "" {
|
||||
opts.CmdName = version.CmdName()
|
||||
if cmdName == "" {
|
||||
cmdName = version.CmdName()
|
||||
}
|
||||
|
||||
useStdLogger := opts.Logf == nil
|
||||
useStdLogger := logf == nil
|
||||
if useStdLogger {
|
||||
opts.Logf = log.Printf
|
||||
logf = log.Printf
|
||||
}
|
||||
tryFixLogStateLocation(opts.Dir, opts.CmdName, opts.Logf)
|
||||
tryFixLogStateLocation(dir, cmdName, logf)
|
||||
|
||||
cfgPath := filepath.Join(opts.Dir, fmt.Sprintf("%s.log.conf", opts.CmdName))
|
||||
cfgPath := filepath.Join(dir, fmt.Sprintf("%s.log.conf", cmdName))
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
switch opts.CmdName {
|
||||
switch cmdName {
|
||||
case "tailscaled":
|
||||
// Tailscale 1.14 and before stored state under %LocalAppData%
|
||||
// (usually "C:\WINDOWS\system32\config\systemprofile\AppData\Local"
|
||||
@@ -581,7 +542,7 @@ func (opts Options) New() *Policy {
|
||||
cfgPath = paths.TryConfigFileMigration(earlyLogf, oldPath, cfgPath)
|
||||
case "tailscale-ipn":
|
||||
for _, oldBase := range []string{"wg64.log.conf", "wg32.log.conf"} {
|
||||
oldConf := filepath.Join(opts.Dir, oldBase)
|
||||
oldConf := filepath.Join(dir, oldBase)
|
||||
if fi, err := os.Stat(oldConf); err == nil && fi.Mode().IsRegular() {
|
||||
cfgPath = paths.TryConfigFileMigration(earlyLogf, oldConf, cfgPath)
|
||||
break
|
||||
@@ -594,9 +555,9 @@ func (opts Options) New() *Policy {
|
||||
if err != nil {
|
||||
earlyLogf("logpolicy.ConfigFromFile %v: %v", cfgPath, err)
|
||||
}
|
||||
if err := newc.Validate(opts.Collection); err != nil {
|
||||
if err := newc.Validate(collection); err != nil {
|
||||
earlyLogf("logpolicy.Config.Validate for %v: %v", cfgPath, err)
|
||||
newc = NewConfig(opts.Collection)
|
||||
newc = NewConfig(collection)
|
||||
if err := newc.Save(cfgPath); err != nil {
|
||||
earlyLogf("logpolicy.Config.Save for %v: %v", cfgPath, err)
|
||||
}
|
||||
@@ -607,39 +568,31 @@ func (opts Options) New() *Policy {
|
||||
PrivateID: newc.PrivateID,
|
||||
Stderr: logWriter{console},
|
||||
CompressLogs: true,
|
||||
HTTPC: &http.Client{Transport: NewLogtailTransport(logtail.DefaultHost, netMon, health, logf)},
|
||||
}
|
||||
if opts.Collection == logtail.CollectionNode {
|
||||
if collection == logtail.CollectionNode {
|
||||
conf.MetricsDelta = clientmetric.EncodeLogTailMetricsDelta
|
||||
conf.IncludeProcID = true
|
||||
conf.IncludeProcSequence = true
|
||||
}
|
||||
|
||||
if envknob.NoLogsNoSupport() || testenv.InTest() {
|
||||
opts.Logf("You have disabled logging. Tailscale will not be able to provide support.")
|
||||
logf("You have disabled logging. Tailscale will not be able to provide support.")
|
||||
conf.HTTPC = &http.Client{Transport: noopPretendSuccessTransport{}}
|
||||
} else {
|
||||
// Only attach an on-disk filch buffer if we are going to be sending logs.
|
||||
// No reason to persist them locally just to drop them later.
|
||||
attachFilchBuffer(&conf, opts.Dir, opts.CmdName, opts.Logf)
|
||||
conf.HTTPC = opts.HTTPC
|
||||
attachFilchBuffer(&conf, dir, cmdName, logf)
|
||||
|
||||
if conf.HTTPC == nil {
|
||||
logHost := logtail.DefaultHost
|
||||
if val := getLogTarget(); val != "" {
|
||||
opts.Logf("You have enabled a non-default log target. Doing without being told to by Tailscale staff or your network administrator will make getting support difficult.")
|
||||
conf.BaseURL = val
|
||||
u, _ := url.Parse(val)
|
||||
logHost = u.Host
|
||||
}
|
||||
conf.HTTPC = &http.Client{Transport: TransportOptions{
|
||||
Host: logHost,
|
||||
NetMon: opts.NetMon,
|
||||
Health: opts.Health,
|
||||
Logf: opts.Logf,
|
||||
}.New()}
|
||||
if val := getLogTarget(); val != "" {
|
||||
logf("You have enabled a non-default log target. Doing without being told to by Tailscale staff or your network administrator will make getting support difficult.")
|
||||
conf.BaseURL = val
|
||||
u, _ := url.Parse(val)
|
||||
conf.HTTPC = &http.Client{Transport: NewLogtailTransport(u.Host, netMon, health, logf)}
|
||||
}
|
||||
|
||||
}
|
||||
lw := logtail.NewLogger(conf, opts.Logf)
|
||||
lw := logtail.NewLogger(conf, logf)
|
||||
|
||||
var logOutput io.Writer = lw
|
||||
|
||||
@@ -657,19 +610,19 @@ func (opts Options) New() *Policy {
|
||||
log.SetOutput(logOutput)
|
||||
}
|
||||
|
||||
opts.Logf("Program starting: v%v, Go %v: %#v",
|
||||
logf("Program starting: v%v, Go %v: %#v",
|
||||
version.Long(),
|
||||
goVersion(),
|
||||
os.Args)
|
||||
opts.Logf("LogID: %v", newc.PublicID)
|
||||
logf("LogID: %v", newc.PublicID)
|
||||
if earlyErrBuf.Len() != 0 {
|
||||
opts.Logf("%s", earlyErrBuf.Bytes())
|
||||
logf("%s", earlyErrBuf.Bytes())
|
||||
}
|
||||
|
||||
return &Policy{
|
||||
Logtail: lw,
|
||||
PublicID: newc.PublicID,
|
||||
Logf: opts.Logf,
|
||||
Logf: logf,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,48 +763,23 @@ func dialContext(ctx context.Context, netw, addr string, netMon *netmon.Monitor,
|
||||
return c, err
|
||||
}
|
||||
|
||||
// Deprecated: Use [TransportOptions.New] instead.
|
||||
// NewLogtailTransport returns an HTTP Transport particularly suited to uploading
|
||||
// logs to the given host name. See DialContext for details on how it works.
|
||||
//
|
||||
// The netMon parameter is optional. It should be specified in environments where
|
||||
// Tailscaled is manipulating the routing table.
|
||||
//
|
||||
// The logf parameter is optional; if non-nil, logs are printed using the
|
||||
// provided function; if nil, log.Printf will be used instead.
|
||||
func NewLogtailTransport(host string, netMon *netmon.Monitor, health *health.Tracker, logf logger.Logf) http.RoundTripper {
|
||||
return TransportOptions{Host: host, NetMon: netMon, Health: health, Logf: logf}.New()
|
||||
}
|
||||
|
||||
// TransportOptions is used to construct an [http.RoundTripper].
|
||||
type TransportOptions struct {
|
||||
// Host is the optional hostname of the logs server.
|
||||
// If empty, then [logtail.DefaultHost] is used.
|
||||
Host string
|
||||
|
||||
// NetMon is an optional parameter for monitoring.
|
||||
// If non-nil, it's used to do faster interface lookups.
|
||||
NetMon *netmon.Monitor
|
||||
|
||||
// Health is an optional parameter for health status.
|
||||
// If non-nil, it's used to construct the default HTTP client.
|
||||
Health *health.Tracker
|
||||
|
||||
// Logf is an optional logger to use.
|
||||
// If nil, [log.Printf] will be used instead.
|
||||
Logf logger.Logf
|
||||
|
||||
// TLSClientConfig is an optional TLS configuration to use.
|
||||
// If non-nil, the configuration will be cloned.
|
||||
TLSClientConfig *tls.Config
|
||||
}
|
||||
|
||||
// New returns an HTTP Transport particularly suited to uploading logs
|
||||
// to the given host name. See [DialContext] for details on how it works.
|
||||
func (opts TransportOptions) New() http.RoundTripper {
|
||||
if testenv.InTest() {
|
||||
return noopPretendSuccessTransport{}
|
||||
}
|
||||
if opts.NetMon == nil {
|
||||
opts.NetMon = netmon.NewStatic()
|
||||
if netMon == nil {
|
||||
netMon = netmon.NewStatic()
|
||||
}
|
||||
// Start with a copy of http.DefaultTransport and tweak it a bit.
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if opts.TLSClientConfig != nil {
|
||||
tr.TLSClientConfig = opts.TLSClientConfig.Clone()
|
||||
}
|
||||
|
||||
tr.Proxy = tshttpproxy.ProxyFromEnvironment
|
||||
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
|
||||
@@ -862,10 +790,10 @@ func (opts TransportOptions) New() http.RoundTripper {
|
||||
tr.DisableCompression = true
|
||||
|
||||
// Log whenever we dial:
|
||||
if opts.Logf == nil {
|
||||
opts.Logf = log.Printf
|
||||
if logf == nil {
|
||||
logf = log.Printf
|
||||
}
|
||||
tr.DialContext = MakeDialFunc(opts.NetMon, opts.Logf)
|
||||
tr.DialContext = MakeDialFunc(netMon, logf)
|
||||
|
||||
// We're uploading logs ideally infrequently, with specific timing that will
|
||||
// change over time. Try to keep the connection open, to avoid repeatedly
|
||||
@@ -887,8 +815,7 @@ func (opts TransportOptions) New() http.RoundTripper {
|
||||
tr.TLSNextProto = map[string]func(authority string, c *tls.Conn) http.RoundTripper{}
|
||||
}
|
||||
|
||||
host := cmp.Or(opts.Host, logtail.DefaultHost)
|
||||
tr.TLSClientConfig = tlsdial.Config(host, opts.Health, tr.TLSClientConfig)
|
||||
tr.TLSClientConfig = tlsdial.Config(host, health, tr.TLSClientConfig)
|
||||
// Force TLS 1.3 since we know log.tailscale.io supports it.
|
||||
tr.TLSClientConfig.MinVersion = tls.VersionTLS13
|
||||
|
||||
|
||||
@@ -597,22 +597,18 @@ func newConn(ctx context.Context, dm *tailcfg.DERPMap, n *tailcfg.DERPNode, isPr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only verify TLS state if this is a prober.
|
||||
if isProber {
|
||||
cs, ok := dc.TLSConnectionState()
|
||||
if !ok {
|
||||
dc.Close()
|
||||
return nil, errors.New("no TLS state")
|
||||
}
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
dc.Close()
|
||||
return nil, errors.New("no peer certificates")
|
||||
}
|
||||
if cs.ServerName != n.HostName {
|
||||
dc.Close()
|
||||
return nil, fmt.Errorf("TLS server name %q != derp hostname %q", cs.ServerName, n.HostName)
|
||||
}
|
||||
cs, ok := dc.TLSConnectionState()
|
||||
if !ok {
|
||||
dc.Close()
|
||||
return nil, errors.New("no TLS state")
|
||||
}
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
dc.Close()
|
||||
return nil, errors.New("no peer certificates")
|
||||
}
|
||||
if cs.ServerName != n.HostName {
|
||||
dc.Close()
|
||||
return nil, fmt.Errorf("TLS server name %q != derp hostname %q", cs.ServerName, n.HostName)
|
||||
}
|
||||
|
||||
errc := make(chan error, 1)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -44,6 +45,7 @@ import (
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -78,14 +80,16 @@ type server struct {
|
||||
logf logger.Logf
|
||||
tailscaledPath string
|
||||
|
||||
timeNow func() time.Time // or nil for time.Now
|
||||
pubKeyHTTPClient *http.Client // or nil for http.DefaultClient
|
||||
timeNow func() time.Time // or nil for time.Now
|
||||
|
||||
sessionWaitGroup sync.WaitGroup
|
||||
|
||||
// mu protects the following
|
||||
mu sync.Mutex
|
||||
activeConns map[*conn]bool // set; value is always true
|
||||
shutdownCalled bool
|
||||
mu sync.Mutex
|
||||
activeConns map[*conn]bool // set; value is always true
|
||||
fetchPublicKeysCache map[string]pubKeyCacheEntry // by https URL
|
||||
shutdownCalled bool
|
||||
}
|
||||
|
||||
func (srv *server) now() time.Time {
|
||||
@@ -200,6 +204,7 @@ func (srv *server) OnPolicyChange() {
|
||||
//
|
||||
// Do the user auth
|
||||
// - NoClientAuthHandler
|
||||
// - PublicKeyHandler (only if NoClientAuthHandler returns errPubKeyRequired)
|
||||
//
|
||||
// Once auth is done, the conn can be multiplexed with multiple sessions and
|
||||
// channels concurrently. At which point any of the following can be called
|
||||
@@ -229,9 +234,10 @@ type conn struct {
|
||||
finalAction *tailcfg.SSHAction // set by doPolicyAuth or resolveNextAction
|
||||
finalActionErr error // set by doPolicyAuth or resolveNextAction
|
||||
|
||||
info *sshConnInfo // set by setInfo
|
||||
localUser *userMeta // set by doPolicyAuth
|
||||
userGroupIDs []string // set by doPolicyAuth
|
||||
info *sshConnInfo // set by setInfo
|
||||
localUser *userMeta // set by doPolicyAuth
|
||||
userGroupIDs []string // set by doPolicyAuth
|
||||
pubKey gossh.PublicKey // set by doPolicyAuth
|
||||
acceptEnv []string
|
||||
|
||||
// mu protects the following fields.
|
||||
@@ -262,6 +268,9 @@ func (c *conn) isAuthorized(ctx ssh.Context) error {
|
||||
action := c.currentAction
|
||||
for {
|
||||
if action.Accept {
|
||||
if c.pubKey != nil {
|
||||
metricPublicKeyAccepts.Add(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if action.Reject || action.HoldAndDelegate == "" {
|
||||
@@ -284,6 +293,10 @@ func (c *conn) isAuthorized(ctx ssh.Context) error {
|
||||
// policy.
|
||||
var errDenied = errors.New("ssh: access denied")
|
||||
|
||||
// errPubKeyRequired is returned by NoClientAuthCallback to make the client
|
||||
// resort to public-key auth; not user visible.
|
||||
var errPubKeyRequired = errors.New("ssh publickey required")
|
||||
|
||||
// NoClientAuthCallback implements gossh.NoClientAuthCallback and is called by
|
||||
// the ssh.Server when the client first connects with the "none"
|
||||
// authentication method.
|
||||
@@ -292,12 +305,13 @@ var errDenied = errors.New("ssh: access denied")
|
||||
// starting it afresh). It returns an error if the policy evaluation fails, or
|
||||
// if the decision is "reject"
|
||||
//
|
||||
// It either returns nil (accept) or errDenied (reject). The errors may be wrapped.
|
||||
// It either returns nil (accept) or errPubKeyRequired or errDenied
|
||||
// (reject). The errors may be wrapped.
|
||||
func (c *conn) NoClientAuthCallback(ctx ssh.Context) error {
|
||||
if c.insecureSkipTailscaleAuth {
|
||||
return nil
|
||||
}
|
||||
if err := c.doPolicyAuth(ctx); err != nil {
|
||||
if err := c.doPolicyAuth(ctx, nil /* no pub key */); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.isAuthorized(ctx); err != nil {
|
||||
@@ -318,6 +332,8 @@ func (c *conn) nextAuthMethodCallback(cm gossh.ConnMetadata, prevErrors []error)
|
||||
switch {
|
||||
case c.anyPasswordIsOkay:
|
||||
nextMethod = append(nextMethod, "password")
|
||||
case slicesx.LastEqual(prevErrors, errPubKeyRequired):
|
||||
nextMethod = append(nextMethod, "publickey")
|
||||
}
|
||||
|
||||
// The fake "tailscale" method is always appended to next so OpenSSH renders
|
||||
@@ -337,20 +353,41 @@ func (c *conn) fakePasswordHandler(ctx ssh.Context, password string) bool {
|
||||
return c.anyPasswordIsOkay
|
||||
}
|
||||
|
||||
// doPolicyAuth verifies that conn can proceed.
|
||||
// It returns nil if the matching policy action is Accept or
|
||||
// HoldAndDelegate. Otherwise, it returns errDenied.
|
||||
func (c *conn) doPolicyAuth(ctx ssh.Context) error {
|
||||
// PublicKeyHandler implements ssh.PublicKeyHandler is called by the
|
||||
// ssh.Server when the client presents a public key.
|
||||
func (c *conn) PublicKeyHandler(ctx ssh.Context, pubKey ssh.PublicKey) error {
|
||||
if err := c.doPolicyAuth(ctx, pubKey); err != nil {
|
||||
// TODO(maisem/bradfitz): surface the error here.
|
||||
c.logf("rejecting SSH public key %s: %v", bytes.TrimSpace(gossh.MarshalAuthorizedKey(pubKey)), err)
|
||||
return err
|
||||
}
|
||||
if err := c.isAuthorized(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
c.logf("accepting SSH public key %s", bytes.TrimSpace(gossh.MarshalAuthorizedKey(pubKey)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// doPolicyAuth verifies that conn can proceed with the specified (optional)
|
||||
// pubKey. It returns nil if the matching policy action is Accept or
|
||||
// HoldAndDelegate. If pubKey is nil, there was no policy match but there is a
|
||||
// policy that might match a public key it returns errPubKeyRequired. Otherwise,
|
||||
// it returns errDenied.
|
||||
func (c *conn) doPolicyAuth(ctx ssh.Context, pubKey ssh.PublicKey) error {
|
||||
if err := c.setInfo(ctx); err != nil {
|
||||
c.logf("failed to get conninfo: %v", err)
|
||||
return errDenied
|
||||
}
|
||||
a, localUser, acceptEnv, err := c.evaluatePolicy()
|
||||
a, localUser, acceptEnv, err := c.evaluatePolicy(pubKey)
|
||||
if err != nil {
|
||||
if pubKey == nil && c.havePubKeyPolicy() {
|
||||
return errPubKeyRequired
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errDenied, err)
|
||||
}
|
||||
c.action0 = a
|
||||
c.currentAction = a
|
||||
c.pubKey = pubKey
|
||||
c.acceptEnv = acceptEnv
|
||||
if a.Message != "" {
|
||||
if err := ctx.SendAuthBanner(a.Message); err != nil {
|
||||
@@ -411,6 +448,7 @@ func (srv *server) newConn() (*conn, error) {
|
||||
ServerConfigCallback: c.ServerConfig,
|
||||
|
||||
NoClientAuthHandler: c.NoClientAuthCallback,
|
||||
PublicKeyHandler: c.PublicKeyHandler,
|
||||
PasswordHandler: c.fakePasswordHandler,
|
||||
|
||||
Handler: c.handleSessionPostSSHAuth,
|
||||
@@ -478,6 +516,34 @@ func (c *conn) mayForwardLocalPortTo(ctx ssh.Context, destinationHost string, de
|
||||
return false
|
||||
}
|
||||
|
||||
// havePubKeyPolicy reports whether any policy rule may provide access by means
|
||||
// of a ssh.PublicKey.
|
||||
func (c *conn) havePubKeyPolicy() bool {
|
||||
if c.info == nil {
|
||||
panic("havePubKeyPolicy called before setInfo")
|
||||
}
|
||||
// Is there any rule that looks like it'd require a public key for this
|
||||
// sshUser?
|
||||
pol, ok := c.sshPolicy()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, r := range pol.Rules {
|
||||
if c.ruleExpired(r) {
|
||||
continue
|
||||
}
|
||||
if mapLocalUser(r.SSHUsers, c.info.sshUser) == "" {
|
||||
continue
|
||||
}
|
||||
for _, p := range r.Principals {
|
||||
if len(p.PubKeys) > 0 && c.principalMatchesTailscaleIdentity(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sshPolicy returns the SSHPolicy for current node.
|
||||
// If there is no SSHPolicy in the netmap, it returns a debugPolicy
|
||||
// if one is defined.
|
||||
@@ -554,19 +620,117 @@ func (c *conn) setInfo(ctx ssh.Context) error {
|
||||
}
|
||||
|
||||
// evaluatePolicy returns the SSHAction and localUser after evaluating
|
||||
// the SSHPolicy for this conn.
|
||||
func (c *conn) evaluatePolicy() (_ *tailcfg.SSHAction, localUser string, acceptEnv []string, _ error) {
|
||||
// the SSHPolicy for this conn. The pubKey may be nil for "none" auth.
|
||||
func (c *conn) evaluatePolicy(pubKey gossh.PublicKey) (_ *tailcfg.SSHAction, localUser string, acceptEnv []string, _ error) {
|
||||
pol, ok := c.sshPolicy()
|
||||
if !ok {
|
||||
return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no SSH policy")
|
||||
}
|
||||
a, localUser, acceptEnv, ok := c.evalSSHPolicy(pol)
|
||||
a, localUser, acceptEnv, ok := c.evalSSHPolicy(pol, pubKey)
|
||||
if !ok {
|
||||
return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no matching policy")
|
||||
}
|
||||
return a, localUser, acceptEnv, nil
|
||||
}
|
||||
|
||||
// pubKeyCacheEntry is the cache value for an HTTPS URL of public keys (like
|
||||
// "https://github.com/foo.keys")
|
||||
type pubKeyCacheEntry struct {
|
||||
lines []string
|
||||
etag string // if sent by server
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
pubKeyCacheDuration = time.Minute // how long to cache non-empty public keys
|
||||
pubKeyCacheEmptyDuration = 15 * time.Second // how long to cache empty responses
|
||||
)
|
||||
|
||||
func (srv *server) fetchPublicKeysURLCached(url string) (ce pubKeyCacheEntry, ok bool) {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
// Mostly don't care about the size of this cache. Clean rarely.
|
||||
if m := srv.fetchPublicKeysCache; len(m) > 50 {
|
||||
tooOld := srv.now().Add(pubKeyCacheDuration * 10)
|
||||
for k, ce := range m {
|
||||
if ce.at.Before(tooOld) {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
ce, ok = srv.fetchPublicKeysCache[url]
|
||||
if !ok {
|
||||
return ce, false
|
||||
}
|
||||
maxAge := pubKeyCacheDuration
|
||||
if len(ce.lines) == 0 {
|
||||
maxAge = pubKeyCacheEmptyDuration
|
||||
}
|
||||
return ce, srv.now().Sub(ce.at) < maxAge
|
||||
}
|
||||
|
||||
func (srv *server) pubKeyClient() *http.Client {
|
||||
if srv.pubKeyHTTPClient != nil {
|
||||
return srv.pubKeyHTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// fetchPublicKeysURL fetches the public keys from a URL. The strings are in the
|
||||
// the typical public key "type base64-string [comment]" format seen at e.g.
|
||||
// https://github.com/USER.keys
|
||||
func (srv *server) fetchPublicKeysURL(url string) ([]string, error) {
|
||||
if !strings.HasPrefix(url, "https://") {
|
||||
return nil, errors.New("invalid URL scheme")
|
||||
}
|
||||
|
||||
ce, ok := srv.fetchPublicKeysURLCached(url)
|
||||
if ok {
|
||||
return ce.lines, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ce.etag != "" {
|
||||
req.Header.Add("If-None-Match", ce.etag)
|
||||
}
|
||||
res, err := srv.pubKeyClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var lines []string
|
||||
var etag string
|
||||
switch res.StatusCode {
|
||||
default:
|
||||
err = fmt.Errorf("unexpected status %v", res.Status)
|
||||
srv.logf("fetching public keys from %s: %v", url, err)
|
||||
case http.StatusNotModified:
|
||||
lines = ce.lines
|
||||
etag = ce.etag
|
||||
case http.StatusOK:
|
||||
var all []byte
|
||||
all, err = io.ReadAll(io.LimitReader(res.Body, 4<<10))
|
||||
if s := strings.TrimSpace(string(all)); s != "" {
|
||||
lines = strings.Split(s, "\n")
|
||||
}
|
||||
etag = res.Header.Get("Etag")
|
||||
}
|
||||
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
mak.Set(&srv.fetchPublicKeysCache, url, pubKeyCacheEntry{
|
||||
at: srv.now(),
|
||||
lines: lines,
|
||||
etag: etag,
|
||||
})
|
||||
return lines, err
|
||||
}
|
||||
|
||||
// handleSessionPostSSHAuth runs an SSH session after the SSH-level authentication,
|
||||
// but not necessarily before all the Tailscale-level extra verification has
|
||||
// completed. It also handles SFTP requests.
|
||||
@@ -668,6 +832,18 @@ func (c *conn) expandDelegateURLLocked(actionURL string) string {
|
||||
).Replace(actionURL)
|
||||
}
|
||||
|
||||
func (c *conn) expandPublicKeyURL(pubKeyURL string) string {
|
||||
if !strings.Contains(pubKeyURL, "$") {
|
||||
return pubKeyURL
|
||||
}
|
||||
loginName := c.info.uprof.LoginName
|
||||
localPart, _, _ := strings.Cut(loginName, "@")
|
||||
return strings.NewReplacer(
|
||||
"$LOGINNAME_EMAIL", loginName,
|
||||
"$LOGINNAME_LOCALPART", localPart,
|
||||
).Replace(pubKeyURL)
|
||||
}
|
||||
|
||||
// sshSession is an accepted Tailscale SSH session.
|
||||
type sshSession struct {
|
||||
ssh.Session
|
||||
@@ -718,7 +894,7 @@ func (c *conn) newSSHSession(s ssh.Session) *sshSession {
|
||||
|
||||
// isStillValid reports whether the conn is still valid.
|
||||
func (c *conn) isStillValid() bool {
|
||||
a, localUser, _, err := c.evaluatePolicy()
|
||||
a, localUser, _, err := c.evaluatePolicy(c.pubKey)
|
||||
c.vlogf("stillValid: %+v %v %v", a, localUser, err)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -1101,9 +1277,9 @@ func (c *conn) ruleExpired(r *tailcfg.SSHRule) bool {
|
||||
return r.RuleExpires.Before(c.srv.now())
|
||||
}
|
||||
|
||||
func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, ok bool) {
|
||||
func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy, pubKey gossh.PublicKey) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, ok bool) {
|
||||
for _, r := range pol.Rules {
|
||||
if a, localUser, acceptEnv, err := c.matchRule(r); err == nil {
|
||||
if a, localUser, acceptEnv, err := c.matchRule(r, pubKey); err == nil {
|
||||
return a, localUser, acceptEnv, true
|
||||
}
|
||||
}
|
||||
@@ -1120,7 +1296,7 @@ var (
|
||||
errInvalidConn = errors.New("invalid connection state")
|
||||
)
|
||||
|
||||
func (c *conn) matchRule(r *tailcfg.SSHRule) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, err error) {
|
||||
func (c *conn) matchRule(r *tailcfg.SSHRule, pubKey gossh.PublicKey) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, err error) {
|
||||
defer func() {
|
||||
c.vlogf("matchRule(%+v): %v", r, err)
|
||||
}()
|
||||
@@ -1150,7 +1326,9 @@ func (c *conn) matchRule(r *tailcfg.SSHRule) (a *tailcfg.SSHAction, localUser st
|
||||
return nil, "", nil, errUserMatch
|
||||
}
|
||||
}
|
||||
if !c.anyPrincipalMatches(r.Principals) {
|
||||
if ok, err := c.anyPrincipalMatches(r.Principals, pubKey); err != nil {
|
||||
return nil, "", nil, err
|
||||
} else if !ok {
|
||||
return nil, "", nil, errPrincipalMatch
|
||||
}
|
||||
return r.Action, localUser, r.AcceptEnv, nil
|
||||
@@ -1167,20 +1345,30 @@ func mapLocalUser(ruleSSHUsers map[string]string, reqSSHUser string) (localUser
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *conn) anyPrincipalMatches(ps []*tailcfg.SSHPrincipal) bool {
|
||||
func (c *conn) anyPrincipalMatches(ps []*tailcfg.SSHPrincipal, pubKey gossh.PublicKey) (bool, error) {
|
||||
for _, p := range ps {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if c.principalMatchesTailscaleIdentity(p) {
|
||||
return true
|
||||
if ok, err := c.principalMatches(p, pubKey); err != nil {
|
||||
return false, err
|
||||
} else if ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (c *conn) principalMatches(p *tailcfg.SSHPrincipal, pubKey gossh.PublicKey) (bool, error) {
|
||||
if !c.principalMatchesTailscaleIdentity(p) {
|
||||
return false, nil
|
||||
}
|
||||
return c.principalMatchesPubKey(p, pubKey)
|
||||
}
|
||||
|
||||
// principalMatchesTailscaleIdentity reports whether one of p's four fields
|
||||
// that match the Tailscale identity match (Node, NodeIP, UserLogin, Any).
|
||||
// This function does not consider PubKeys.
|
||||
func (c *conn) principalMatchesTailscaleIdentity(p *tailcfg.SSHPrincipal) bool {
|
||||
ci := c.info
|
||||
if p.Any {
|
||||
@@ -1200,6 +1388,42 @@ func (c *conn) principalMatchesTailscaleIdentity(p *tailcfg.SSHPrincipal) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *conn) principalMatchesPubKey(p *tailcfg.SSHPrincipal, clientPubKey gossh.PublicKey) (bool, error) {
|
||||
if len(p.PubKeys) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
if clientPubKey == nil {
|
||||
return false, nil
|
||||
}
|
||||
knownKeys := p.PubKeys
|
||||
if len(knownKeys) == 1 && strings.HasPrefix(knownKeys[0], "https://") {
|
||||
var err error
|
||||
knownKeys, err = c.srv.fetchPublicKeysURL(c.expandPublicKeyURL(knownKeys[0]))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
for _, knownKey := range knownKeys {
|
||||
if pubKeyMatchesAuthorizedKey(clientPubKey, knownKey) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func pubKeyMatchesAuthorizedKey(pubKey ssh.PublicKey, wantKey string) bool {
|
||||
wantKeyType, rest, ok := strings.Cut(wantKey, " ")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if pubKey.Type() != wantKeyType {
|
||||
return false
|
||||
}
|
||||
wantKeyB64, _, _ := strings.Cut(rest, " ")
|
||||
wantKeyData, _ := base64.StdEncoding.DecodeString(wantKeyB64)
|
||||
return len(wantKeyData) > 0 && bytes.Equal(pubKey.Marshal(), wantKeyData)
|
||||
}
|
||||
|
||||
func randBytes(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -1525,6 +1749,7 @@ func envEq(a, b string) bool {
|
||||
var (
|
||||
metricActiveSessions = clientmetric.NewGauge("ssh_active_sessions")
|
||||
metricIncomingConnections = clientmetric.NewCounter("ssh_incoming_connections")
|
||||
metricPublicKeyAccepts = clientmetric.NewCounter("ssh_publickey_accepts") // accepted subset of ssh_publickey_connections
|
||||
metricTerminalAccept = clientmetric.NewCounter("ssh_terminalaction_accept")
|
||||
metricTerminalReject = clientmetric.NewCounter("ssh_terminalaction_reject")
|
||||
metricTerminalMalformed = clientmetric.NewCounter("ssh_terminalaction_malformed")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -228,7 +229,7 @@ func TestMatchRule(t *testing.T) {
|
||||
info: tt.ci,
|
||||
srv: &server{logf: t.Logf},
|
||||
}
|
||||
got, gotUser, gotAcceptEnv, err := c.matchRule(tt.rule)
|
||||
got, gotUser, gotAcceptEnv, err := c.matchRule(tt.rule, nil)
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("err = %v; want %v", err, tt.wantErr)
|
||||
}
|
||||
@@ -347,7 +348,7 @@ func TestEvalSSHPolicy(t *testing.T) {
|
||||
info: tt.ci,
|
||||
srv: &server{logf: t.Logf},
|
||||
}
|
||||
got, gotUser, gotAcceptEnv, match := c.evalSSHPolicy(tt.policy)
|
||||
got, gotUser, gotAcceptEnv, match := c.evalSSHPolicy(tt.policy, nil)
|
||||
if match != tt.wantMatch {
|
||||
t.Errorf("match = %v; want %v", match, tt.wantMatch)
|
||||
}
|
||||
@@ -1128,6 +1129,89 @@ func parseEnv(out []byte) map[string]string {
|
||||
return e
|
||||
}
|
||||
|
||||
func TestPublicKeyFetching(t *testing.T) {
|
||||
var reqsTotal, reqsIfNoneMatchHit, reqsIfNoneMatchMiss int32
|
||||
ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32((&reqsTotal), 1)
|
||||
etag := fmt.Sprintf("W/%q", sha256.Sum256([]byte(r.URL.Path)))
|
||||
w.Header().Set("Etag", etag)
|
||||
if v := r.Header.Get("If-None-Match"); v != "" {
|
||||
if v == etag {
|
||||
atomic.AddInt32(&reqsIfNoneMatchHit, 1)
|
||||
w.WriteHeader(304)
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&reqsIfNoneMatchMiss, 1)
|
||||
}
|
||||
io.WriteString(w, "foo\nbar\n"+string(r.URL.Path)+"\n")
|
||||
}))
|
||||
ts.StartTLS()
|
||||
defer ts.Close()
|
||||
keys := ts.URL
|
||||
|
||||
clock := &tstest.Clock{}
|
||||
srv := &server{
|
||||
pubKeyHTTPClient: ts.Client(),
|
||||
timeNow: clock.Now,
|
||||
}
|
||||
for range 2 {
|
||||
got, err := srv.fetchPublicKeysURL(keys + "/alice.keys")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"foo", "bar", "/alice.keys"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
if got, want := atomic.LoadInt32(&reqsTotal), int32(1); got != want {
|
||||
t.Errorf("got %d requests; want %d", got, want)
|
||||
}
|
||||
if got, want := atomic.LoadInt32(&reqsIfNoneMatchHit), int32(0); got != want {
|
||||
t.Errorf("got %d etag hits; want %d", got, want)
|
||||
}
|
||||
clock.Advance(5 * time.Minute)
|
||||
got, err := srv.fetchPublicKeysURL(keys + "/alice.keys")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"foo", "bar", "/alice.keys"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %q; want %q", got, want)
|
||||
}
|
||||
if got, want := atomic.LoadInt32(&reqsTotal), int32(2); got != want {
|
||||
t.Errorf("got %d requests; want %d", got, want)
|
||||
}
|
||||
if got, want := atomic.LoadInt32(&reqsIfNoneMatchHit), int32(1); got != want {
|
||||
t.Errorf("got %d etag hits; want %d", got, want)
|
||||
}
|
||||
if got, want := atomic.LoadInt32(&reqsIfNoneMatchMiss), int32(0); got != want {
|
||||
t.Errorf("got %d etag misses; want %d", got, want)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestExpandPublicKeyURL(t *testing.T) {
|
||||
c := &conn{
|
||||
info: &sshConnInfo{
|
||||
uprof: tailcfg.UserProfile{
|
||||
LoginName: "bar@baz.tld",
|
||||
},
|
||||
},
|
||||
}
|
||||
if got, want := c.expandPublicKeyURL("foo"), "foo"; got != want {
|
||||
t.Errorf("basic: got %q; want %q", got, want)
|
||||
}
|
||||
if got, want := c.expandPublicKeyURL("https://example.com/$LOGINNAME_LOCALPART.keys"), "https://example.com/bar.keys"; got != want {
|
||||
t.Errorf("localpart: got %q; want %q", got, want)
|
||||
}
|
||||
if got, want := c.expandPublicKeyURL("https://example.com/keys?email=$LOGINNAME_EMAIL"), "https://example.com/keys?email=bar@baz.tld"; got != want {
|
||||
t.Errorf("email: got %q; want %q", got, want)
|
||||
}
|
||||
c.info = new(sshConnInfo)
|
||||
if got, want := c.expandPublicKeyURL("https://example.com/keys?email=$LOGINNAME_EMAIL"), "https://example.com/keys?email="; got != want {
|
||||
t.Errorf("on empty: got %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptEnvPair(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
|
||||
@@ -152,8 +152,7 @@ type CapabilityVersion int
|
||||
// - 107: 2024-10-30: add App Connector to conffile (PR #13942)
|
||||
// - 108: 2024-11-08: Client sends ServicesHash in Hostinfo, understands c2n GET /vip-services.
|
||||
// - 109: 2024-11-18: Client supports filtertype.Match.SrcCaps (issue #12542)
|
||||
// - 110: 2024-12-12: removed never-before-used Tailscale SSH public key support (#14373)
|
||||
const CurrentCapabilityVersion CapabilityVersion = 110
|
||||
const CurrentCapabilityVersion CapabilityVersion = 109
|
||||
|
||||
type StableID string
|
||||
|
||||
@@ -2526,13 +2525,16 @@ type SSHPrincipal struct {
|
||||
Any bool `json:"any,omitempty"` // if true, match any connection
|
||||
// TODO(bradfitz): add StableUserID, once that exists
|
||||
|
||||
// UnusedPubKeys was public key support. It never became an official product
|
||||
// feature and so as of 2024-12-12 is being removed.
|
||||
// This stub exists to remind us not to re-use the JSON field name "pubKeys"
|
||||
// in the future if we bring it back with different semantics.
|
||||
// PubKeys, if non-empty, means that this SSHPrincipal only
|
||||
// matches if one of these public keys is presented by the user.
|
||||
//
|
||||
// Deprecated: do not use. It does nothing.
|
||||
UnusedPubKeys []string `json:"pubKeys,omitempty"`
|
||||
// As a special case, if len(PubKeys) == 1 and PubKeys[0] starts
|
||||
// with "https://", then it's fetched (like https://github.com/username.keys).
|
||||
// In that case, the following variable expansions are also supported
|
||||
// in the URL:
|
||||
// * $LOGINNAME_EMAIL ("foo@bar.com" or "foo@github")
|
||||
// * $LOGINNAME_LOCALPART (the "foo" from either of the above)
|
||||
PubKeys []string `json:"pubKeys,omitempty"`
|
||||
}
|
||||
|
||||
// SSHAction is how to handle an incoming connection.
|
||||
|
||||
@@ -556,17 +556,17 @@ func (src *SSHPrincipal) Clone() *SSHPrincipal {
|
||||
}
|
||||
dst := new(SSHPrincipal)
|
||||
*dst = *src
|
||||
dst.UnusedPubKeys = append(src.UnusedPubKeys[:0:0], src.UnusedPubKeys...)
|
||||
dst.PubKeys = append(src.PubKeys[:0:0], src.PubKeys...)
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _SSHPrincipalCloneNeedsRegeneration = SSHPrincipal(struct {
|
||||
Node StableNodeID
|
||||
NodeIP string
|
||||
UserLogin string
|
||||
Any bool
|
||||
UnusedPubKeys []string
|
||||
Node StableNodeID
|
||||
NodeIP string
|
||||
UserLogin string
|
||||
Any bool
|
||||
PubKeys []string
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of ControlDialPlan.
|
||||
|
||||
@@ -1260,21 +1260,19 @@ func (v *SSHPrincipalView) UnmarshalJSON(b []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v SSHPrincipalView) Node() StableNodeID { return v.ж.Node }
|
||||
func (v SSHPrincipalView) NodeIP() string { return v.ж.NodeIP }
|
||||
func (v SSHPrincipalView) UserLogin() string { return v.ж.UserLogin }
|
||||
func (v SSHPrincipalView) Any() bool { return v.ж.Any }
|
||||
func (v SSHPrincipalView) UnusedPubKeys() views.Slice[string] {
|
||||
return views.SliceOf(v.ж.UnusedPubKeys)
|
||||
}
|
||||
func (v SSHPrincipalView) Node() StableNodeID { return v.ж.Node }
|
||||
func (v SSHPrincipalView) NodeIP() string { return v.ж.NodeIP }
|
||||
func (v SSHPrincipalView) UserLogin() string { return v.ж.UserLogin }
|
||||
func (v SSHPrincipalView) Any() bool { return v.ж.Any }
|
||||
func (v SSHPrincipalView) PubKeys() views.Slice[string] { return views.SliceOf(v.ж.PubKeys) }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _SSHPrincipalViewNeedsRegeneration = SSHPrincipal(struct {
|
||||
Node StableNodeID
|
||||
NodeIP string
|
||||
UserLogin string
|
||||
Any bool
|
||||
UnusedPubKeys []string
|
||||
Node StableNodeID
|
||||
NodeIP string
|
||||
UserLogin string
|
||||
Any bool
|
||||
PubKeys []string
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of ControlDialPlan.
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package bools contains the [Compare] and [Select] functions.
|
||||
package bools
|
||||
|
||||
// Compare compares two boolean values as if false is ordered before true.
|
||||
func Compare[T ~bool](x, y T) int {
|
||||
switch {
|
||||
case x == false && y == true:
|
||||
return -1
|
||||
case x == true && y == false:
|
||||
return +1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// IfElse is a ternary operator that returns trueVal if condExpr is true
|
||||
// otherwise it returns falseVal.
|
||||
// IfElse(c, a, b) is roughly equivalent to (c ? a : b) in languages like C.
|
||||
func IfElse[T any](condExpr bool, trueVal T, falseVal T) T {
|
||||
if condExpr {
|
||||
return trueVal
|
||||
} else {
|
||||
return falseVal
|
||||
}
|
||||
}
|
||||
17
types/bools/compare.go
Normal file
17
types/bools/compare.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package bools contains the bools.Compare function.
|
||||
package bools
|
||||
|
||||
// Compare compares two boolean values as if false is ordered before true.
|
||||
func Compare[T ~bool](x, y T) int {
|
||||
switch {
|
||||
case x == false && y == true:
|
||||
return -1
|
||||
case x == true && y == false:
|
||||
return +1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,3 @@ func TestCompare(t *testing.T) {
|
||||
t.Errorf("Compare(true, true) = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfElse(t *testing.T) {
|
||||
if got := IfElse(true, 0, 1); got != 0 {
|
||||
t.Errorf("IfElse(true, 0, 1) = %v, want 0", got)
|
||||
}
|
||||
if got := IfElse(false, 0, 1); got != 1 {
|
||||
t.Errorf("IfElse(false, 0, 1) = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package iox provides types to implement [io] functionality.
|
||||
package iox
|
||||
|
||||
// TODO(https://go.dev/issue/21670): Deprecate or remove this functionality
|
||||
// once the Go language supports implementing an 1-method interface directly
|
||||
// using a function value of a matching signature.
|
||||
|
||||
// ReaderFunc implements [io.Reader] using the underlying function value.
|
||||
type ReaderFunc func([]byte) (int, error)
|
||||
|
||||
func (f ReaderFunc) Read(b []byte) (int, error) {
|
||||
return f(b)
|
||||
}
|
||||
|
||||
// WriterFunc implements [io.Writer] using the underlying function value.
|
||||
type WriterFunc func([]byte) (int, error)
|
||||
|
||||
func (f WriterFunc) Write(b []byte) (int, error) {
|
||||
return f(b)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package iox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
func TestCopy(t *testing.T) {
|
||||
const testdata = "the quick brown fox jumped over the lazy dog"
|
||||
src := testdata
|
||||
bb := new(bytes.Buffer)
|
||||
if got := must.Get(io.Copy(bb, ReaderFunc(func(b []byte) (n int, err error) {
|
||||
n = copy(b[:min(len(b), 7)], src)
|
||||
src = src[n:]
|
||||
if len(src) == 0 {
|
||||
err = io.EOF
|
||||
}
|
||||
return n, err
|
||||
}))); int(got) != len(testdata) {
|
||||
t.Errorf("copy = %d, want %d", got, len(testdata))
|
||||
}
|
||||
var dst []byte
|
||||
if got := must.Get(io.Copy(WriterFunc(func(b []byte) (n int, err error) {
|
||||
dst = append(dst, b...)
|
||||
return len(b), nil
|
||||
}), iotest.OneByteReader(bb))); int(got) != len(testdata) {
|
||||
t.Errorf("copy = %d, want %d", got, len(testdata))
|
||||
}
|
||||
if string(dst) != testdata {
|
||||
t.Errorf("copy = %q, want %q", dst, testdata)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
package dnsname
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/vizerror"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -36,7 +36,7 @@ func ToFQDN(s string) (FQDN, error) {
|
||||
totalLen += 1 // account for missing dot
|
||||
}
|
||||
if totalLen > maxNameLength {
|
||||
return "", vizerror.Errorf("%q is too long to be a DNS name", s)
|
||||
return "", fmt.Errorf("%q is too long to be a DNS name", s)
|
||||
}
|
||||
|
||||
st := 0
|
||||
@@ -54,7 +54,7 @@ func ToFQDN(s string) (FQDN, error) {
|
||||
//
|
||||
// See https://github.com/tailscale/tailscale/issues/2024 for more.
|
||||
if len(label) == 0 || len(label) > maxLabelLength {
|
||||
return "", vizerror.Errorf("%q is not a valid DNS label", label)
|
||||
return "", fmt.Errorf("%q is not a valid DNS label", label)
|
||||
}
|
||||
st = i + 1
|
||||
}
|
||||
@@ -97,23 +97,23 @@ func (f FQDN) Contains(other FQDN) bool {
|
||||
// ValidLabel reports whether label is a valid DNS label.
|
||||
func ValidLabel(label string) error {
|
||||
if len(label) == 0 {
|
||||
return vizerror.New("empty DNS label")
|
||||
return errors.New("empty DNS label")
|
||||
}
|
||||
if len(label) > maxLabelLength {
|
||||
return vizerror.Errorf("%q is too long, max length is %d bytes", label, maxLabelLength)
|
||||
return fmt.Errorf("%q is too long, max length is %d bytes", label, maxLabelLength)
|
||||
}
|
||||
if !isalphanum(label[0]) {
|
||||
return vizerror.Errorf("%q is not a valid DNS label: must start with a letter or number", label)
|
||||
return fmt.Errorf("%q is not a valid DNS label: must start with a letter or number", label)
|
||||
}
|
||||
if !isalphanum(label[len(label)-1]) {
|
||||
return vizerror.Errorf("%q is not a valid DNS label: must end with a letter or number", label)
|
||||
return fmt.Errorf("%q is not a valid DNS label: must end with a letter or number", label)
|
||||
}
|
||||
if len(label) < 2 {
|
||||
return nil
|
||||
}
|
||||
for i := 1; i < len(label)-1; i++ {
|
||||
if !isdnschar(label[i]) {
|
||||
return vizerror.Errorf("%q is not a valid DNS label: contains invalid character %q", label, label[i])
|
||||
return fmt.Errorf("%q is not a valid DNS label: contains invalid character %q", label, label[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user