Compare commits

...

15 Commits

Author SHA1 Message Date
Lee Briggs
d998cf0837 cmd/containerboot: introduce TS_STATE env var
Fixes #12180
Fixed #13409

Signed-off-by: Lee Briggs <lee@leebriggs.co.uk>
2024-12-13 11:57:24 +00:00
Brad Fitzpatrick
73128e2523 ssh/tailssh: remove unused public key support
When we first made Tailscale SSH, we assumed people would want public
key support soon after. Turns out that hasn't been the case; people
love the Tailscale identity authentication and check mode.

In light of CVE-2024-45337, just remove all our public key code to not
distract people, and to make the code smaller. We can always get it
back from git if needed.

Updates tailscale/corp#25131
Updates golang/go#70779

Co-authored-by: Percy Wegmann <percy@tailscale.com>
Change-Id: I87a6e79c2215158766a81942227a18b247333c22
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-12-12 11:16:55 -08:00
Adrian Dewhurst
716cb37256 util/dnsname: use vizerror for all errors
The errors emitted by util/dnsname are all written at least moderately
friendly and none of them emit sensitive information. They should be
safe to display to end users.

Updates tailscale/corp#9025

Change-Id: Ic58705075bacf42f56378127532c5f28ff6bfc89
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2024-12-12 10:29:36 -05:00
Joe Tsai
c9188d7760 types/bools: add IfElse (#14272)
The IfElse function is equivalent to the ternary (c ? a : b) operator
in many other languages like C. Unfortunately, this function
cannot perform short-circuit evaluation like in many other languages,
but this is a restriction that's not much different
than the pre-existing cmp.Or function.

The argument against ternary operators in Go is that
nested ternary operators become unreadable
(e.g., (c1 ? (c2 ? a : b) : (c2 ? x : y))).
But a single layer of ternary expressions can sometimes
make code much more readable.

Having the bools.IfElse function gives code authors the
ability to decide whether use of this is more readable or not.
Obviously, code authors will need to be judicious about
their use of this helper function.
Readability is more of an art than a science.

Updates #cleanup

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2024-12-11 10:55:33 -08:00
Joe Tsai
0045860060 types/iox: add function types for Reader and Writer (#14366)
Throughout our codebase we have types that only exist only
to implement an io.Reader or io.Writer, when it would have been
simpler, cleaner, and more readable to use an inlined function literal
that closes over the relevant types.

This is arguably more readable since it keeps the semantic logic
in place rather than have it be isolated elsewhere.

Note that a function literal that closes over some variables
is semantic equivalent to declaring a struct with fields and
having the Read or Write method mutate those fields.

Updates #cleanup

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2024-12-11 10:55:21 -08:00
Irbe Krumina
6e552f66a0 cmd/containerboot: don't attempt to patch a Secret field without permissions (#14365)
Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-12-11 14:58:44 +00:00
Tom Proctor
f1ccdcc713 cmd/k8s-operator,k8s-operator: operator integration tests (#12792)
This is the start of an integration/e2e test suite for the tailscale operator.
It currently only tests two major features, ingress proxy and API server proxy,
but we intend to expand it to cover more features over time. It also only
supports manual runs for now. We intend to integrate it into CI checks in a
separate update when we have planned how to securely provide CI with the secrets
required for connecting to a test tailnet.

Updates #12622

Change-Id: I31e464bb49719348b62a563790f2bc2ba165a11b
Co-authored-by: Irbe Krumina <irbe@tailscale.com>
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-12-11 14:48:57 +00:00
Irbe Krumina
fa655e6ed3 cmd/containerboot: add more tests, check that egress service config only set on kube (#14360)
Updates tailscale/tailscale#14357

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-12-11 12:59:42 +00:00
Irbe Krumina
0cc071f154 cmd/containerboot: don't attempt to write kube Secret in non-kube environments (#14358)
Updates tailscale/tailscale#14354

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-12-11 10:56:12 +00:00
Bjorn Neergaard
8b1d01161b cmd/containerboot: guard kubeClient against nil dereference (#14357)
A method on kc was called unconditionally, even if was not initialized,
leading to a nil pointer dereference when TS_SERVE_CONFIG was set
outside Kubernetes.

Add a guard symmetric with other uses of the kubeClient.

Fixes #14354.

Signed-off-by: Bjorn Neergaard <bjorn@neersighted.com>
2024-12-11 09:52:56 +00:00
dependabot[bot]
d54cd59390 .github: Bump github/codeql-action from 3.27.1 to 3.27.6 (#14332)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.27.1 to 3.27.6.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](4f3212b617...aa57810251)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-10 15:15:11 -07:00
dependabot[bot]
fa28b024d6 .github: Bump actions/cache from 4.1.2 to 4.2.0 (#14331)
Bumps [actions/cache](https://github.com/actions/cache) from 4.1.2 to 4.2.0.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](6849a64899...1bd1e32a3b)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-10 14:32:04 -07:00
Mario Minardi
ea3d0bcfd4 prober,derp/derphttp: make dev-mode DERP probes work without TLS (#14347)
Make dev-mode DERP probes work without TLS. Properly dial port `3340`
when not using HTTPS when dialing nodes in `derphttp_client`. Skip
verifying TLS state in `newConn` if we are not running a prober.

Updates tailscale/corp#24635

Signed-off-by: Percy Wegmann <percy@tailscale.com>
Co-authored-by: Percy Wegmann <percy@tailscale.com>
2024-12-10 10:51:03 -07:00
Mike O'Driscoll
24b243c194 derp: add env var setting server send queue depth (#14334)
Use envknob to configure the per client send
queue depth for the derp server.

Fixes tailscale/corp#24978

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2024-12-10 08:58:27 -05:00
Tom Proctor
06c5e83c20 hostinfo: fix testing in container (#14330)
Previously this unit test failed if it was run in a container. Update the assert
to focus on exactly the condition we are trying to assert: the package type
should only be 'container' if we use the build tag.

Updates #14317

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-12-09 20:42:10 +00:00
29 changed files with 900 additions and 440 deletions

View File

@@ -55,7 +55,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
uses: github/codeql-action/init@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
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@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
uses: github/codeql-action/autobuild@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
# 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@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
uses: github/codeql-action/analyze@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6

View File

@@ -80,7 +80,7 @@ jobs:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
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@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
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@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
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@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
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@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache

View File

@@ -116,7 +116,6 @@ 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

View File

@@ -24,6 +24,7 @@ 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) {

View File

@@ -331,8 +331,10 @@ authLoop:
if err := client.SetServeConfig(ctx, new(ipn.ServeConfig)); err != nil {
log.Fatalf("failed to unset serve config: %v", err)
}
if err := kc.storeHTTPSEndpoint(ctx, ""); err != nil {
log.Fatalf("failed to update HTTPS endpoint in tailscale state: %v", err)
if hasKubeStateStore(cfg) {
if err := kc.storeHTTPSEndpoint(ctx, ""); err != nil {
log.Fatalf("failed to update HTTPS endpoint in tailscale state: %v", err)
}
}
}

View File

@@ -31,6 +31,7 @@ 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"
@@ -57,6 +58,16 @@ 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",
@@ -73,14 +84,16 @@ 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,
"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,
}
resetFiles := func() {
for path, content := range files {
@@ -829,6 +842,101 @@ 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 {

View File

@@ -72,8 +72,10 @@ 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 err := kc.storeHTTPSEndpoint(ctx, certDomain); err != nil {
log.Fatalf("serve proxy: error storing HTTPS endpoint: %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)
}
}
prevServeConfig = sc
}

View File

@@ -44,6 +44,7 @@ type settings struct {
DaemonExtraArgs string
ExtraArgs string
InKubernetes bool
State string
UserspaceMode bool
StateDir string
AcceptDNS *bool
@@ -89,6 +90,7 @@ 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"),
@@ -110,6 +112,19 @@ 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, ",")
@@ -135,6 +150,35 @@ 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 {
@@ -199,6 +243,10 @@ 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
}
@@ -214,6 +262,7 @@ 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 {

View File

@@ -62,17 +62,22 @@ 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}
switch {
case cfg.InKubernetes && cfg.KubeSecret != "":
args = append(args, "--state=kube:"+cfg.KubeSecret)
if cfg.StateDir == "" {
cfg.StateDir = "/tmp"
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")
}
fallthrough
case cfg.StateDir != "":
args = append(args, "--statedir="+cfg.StateDir)
default:
args = append(args, "--state=mem:", "--statedir=/tmp")
}
if cfg.UserspaceMode {

View File

@@ -0,0 +1,108 @@
// 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)
}
}

View File

@@ -0,0 +1,194 @@
// 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)
}

View File

@@ -0,0 +1,156 @@
// 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)
}

View File

@@ -84,11 +84,19 @@ func init() {
}
const (
perClientSendQueueDepth = 32 // packets buffered for sending
writeTimeout = 2 * time.Second
privilegedWriteTimeout = 30 * time.Second // for clients with the mesh key
defaultPerClientSendQueueDepth = 32 // default 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
@@ -190,6 +198,9 @@ 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
}
@@ -377,6 +388,8 @@ 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
}
@@ -849,8 +862,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, perClientSendQueueDepth),
discoSendQueue: make(chan pkt, perClientSendQueueDepth),
sendQueue: make(chan pkt, s.perClientSendQueueDepth),
discoSendQueue: make(chan pkt, s.perClientSendQueueDepth),
sendPongCh: make(chan [8]byte, 1),
peerGone: make(chan peerGoneMsg),
canMesh: s.isMeshPeer(clientInfo),

View File

@@ -6,6 +6,7 @@ package derp
import (
"bufio"
"bytes"
"cmp"
"context"
"crypto/x509"
"encoding/asn1"
@@ -23,6 +24,7 @@ import (
"testing"
"time"
qt "github.com/frankban/quicktest"
"go4.org/mem"
"golang.org/x/time/rate"
"tailscale.com/disco"
@@ -1598,3 +1600,29 @@ 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)
})
}
}

View File

@@ -757,6 +757,9 @@ 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)
}

View File

@@ -35,8 +35,12 @@ remotes/origin/QTSFW_5.0.0`
}
}
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)
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")
}
}

View File

@@ -167,3 +167,14 @@ 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
}

View File

@@ -597,18 +597,22 @@ func newConn(ctx context.Context, dm *tailcfg.DERPMap, n *tailcfg.DERPNode, isPr
if err != nil {
return nil, err
}
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)
// 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)
}
}
errc := make(chan error, 1)

View File

@@ -10,7 +10,6 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -45,7 +44,6 @@ import (
"tailscale.com/util/clientmetric"
"tailscale.com/util/httpm"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
)
var (
@@ -80,16 +78,14 @@ type server struct {
logf logger.Logf
tailscaledPath string
pubKeyHTTPClient *http.Client // or nil for http.DefaultClient
timeNow func() time.Time // or nil for time.Now
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
fetchPublicKeysCache map[string]pubKeyCacheEntry // by https URL
shutdownCalled bool
mu sync.Mutex
activeConns map[*conn]bool // set; value is always true
shutdownCalled bool
}
func (srv *server) now() time.Time {
@@ -204,7 +200,6 @@ 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
@@ -234,10 +229,9 @@ 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
pubKey gossh.PublicKey // set by doPolicyAuth
info *sshConnInfo // set by setInfo
localUser *userMeta // set by doPolicyAuth
userGroupIDs []string // set by doPolicyAuth
acceptEnv []string
// mu protects the following fields.
@@ -268,9 +262,6 @@ 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 == "" {
@@ -293,10 +284,6 @@ 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.
@@ -305,13 +292,12 @@ var errPubKeyRequired = errors.New("ssh publickey required")
// starting it afresh). It returns an error if the policy evaluation fails, or
// if the decision is "reject"
//
// It either returns nil (accept) or errPubKeyRequired or errDenied
// (reject). The errors may be wrapped.
// It either returns nil (accept) 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, nil /* no pub key */); err != nil {
if err := c.doPolicyAuth(ctx); err != nil {
return err
}
if err := c.isAuthorized(ctx); err != nil {
@@ -332,8 +318,6 @@ 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
@@ -353,41 +337,20 @@ func (c *conn) fakePasswordHandler(ctx ssh.Context, password string) bool {
return c.anyPasswordIsOkay
}
// 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 {
// 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 {
if err := c.setInfo(ctx); err != nil {
c.logf("failed to get conninfo: %v", err)
return errDenied
}
a, localUser, acceptEnv, err := c.evaluatePolicy(pubKey)
a, localUser, acceptEnv, err := c.evaluatePolicy()
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 {
@@ -448,7 +411,6 @@ func (srv *server) newConn() (*conn, error) {
ServerConfigCallback: c.ServerConfig,
NoClientAuthHandler: c.NoClientAuthCallback,
PublicKeyHandler: c.PublicKeyHandler,
PasswordHandler: c.fakePasswordHandler,
Handler: c.handleSessionPostSSHAuth,
@@ -516,34 +478,6 @@ 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.
@@ -620,117 +554,19 @@ func (c *conn) setInfo(ctx ssh.Context) error {
}
// evaluatePolicy returns the SSHAction and localUser after evaluating
// 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) {
// the SSHPolicy for this conn.
func (c *conn) evaluatePolicy() (_ *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, pubKey)
a, localUser, acceptEnv, ok := c.evalSSHPolicy(pol)
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.
@@ -832,18 +668,6 @@ 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
@@ -894,7 +718,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(c.pubKey)
a, localUser, _, err := c.evaluatePolicy()
c.vlogf("stillValid: %+v %v %v", a, localUser, err)
if err != nil {
return false
@@ -1277,9 +1101,9 @@ func (c *conn) ruleExpired(r *tailcfg.SSHRule) bool {
return r.RuleExpires.Before(c.srv.now())
}
func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy, pubKey gossh.PublicKey) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, ok bool) {
func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, ok bool) {
for _, r := range pol.Rules {
if a, localUser, acceptEnv, err := c.matchRule(r, pubKey); err == nil {
if a, localUser, acceptEnv, err := c.matchRule(r); err == nil {
return a, localUser, acceptEnv, true
}
}
@@ -1296,7 +1120,7 @@ var (
errInvalidConn = errors.New("invalid connection state")
)
func (c *conn) matchRule(r *tailcfg.SSHRule, pubKey gossh.PublicKey) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, err error) {
func (c *conn) matchRule(r *tailcfg.SSHRule) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, err error) {
defer func() {
c.vlogf("matchRule(%+v): %v", r, err)
}()
@@ -1326,9 +1150,7 @@ func (c *conn) matchRule(r *tailcfg.SSHRule, pubKey gossh.PublicKey) (a *tailcfg
return nil, "", nil, errUserMatch
}
}
if ok, err := c.anyPrincipalMatches(r.Principals, pubKey); err != nil {
return nil, "", nil, err
} else if !ok {
if !c.anyPrincipalMatches(r.Principals) {
return nil, "", nil, errPrincipalMatch
}
return r.Action, localUser, r.AcceptEnv, nil
@@ -1345,30 +1167,20 @@ func mapLocalUser(ruleSSHUsers map[string]string, reqSSHUser string) (localUser
return v
}
func (c *conn) anyPrincipalMatches(ps []*tailcfg.SSHPrincipal, pubKey gossh.PublicKey) (bool, error) {
func (c *conn) anyPrincipalMatches(ps []*tailcfg.SSHPrincipal) bool {
for _, p := range ps {
if p == nil {
continue
}
if ok, err := c.principalMatches(p, pubKey); err != nil {
return false, err
} else if ok {
return true, nil
if c.principalMatchesTailscaleIdentity(p) {
return true
}
}
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)
return false
}
// 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 {
@@ -1388,42 +1200,6 @@ 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 {
@@ -1749,7 +1525,6 @@ 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")

View File

@@ -10,7 +10,6 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
@@ -229,7 +228,7 @@ func TestMatchRule(t *testing.T) {
info: tt.ci,
srv: &server{logf: t.Logf},
}
got, gotUser, gotAcceptEnv, err := c.matchRule(tt.rule, nil)
got, gotUser, gotAcceptEnv, err := c.matchRule(tt.rule)
if err != tt.wantErr {
t.Errorf("err = %v; want %v", err, tt.wantErr)
}
@@ -348,7 +347,7 @@ func TestEvalSSHPolicy(t *testing.T) {
info: tt.ci,
srv: &server{logf: t.Logf},
}
got, gotUser, gotAcceptEnv, match := c.evalSSHPolicy(tt.policy, nil)
got, gotUser, gotAcceptEnv, match := c.evalSSHPolicy(tt.policy)
if match != tt.wantMatch {
t.Errorf("match = %v; want %v", match, tt.wantMatch)
}
@@ -1129,89 +1128,6 @@ 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

View File

@@ -152,7 +152,8 @@ 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)
const CurrentCapabilityVersion CapabilityVersion = 109
// - 110: 2024-12-12: removed never-before-used Tailscale SSH public key support (#14373)
const CurrentCapabilityVersion CapabilityVersion = 110
type StableID string
@@ -2525,16 +2526,13 @@ type SSHPrincipal struct {
Any bool `json:"any,omitempty"` // if true, match any connection
// TODO(bradfitz): add StableUserID, once that exists
// PubKeys, if non-empty, means that this SSHPrincipal only
// matches if one of these public keys is presented by the user.
// 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.
//
// 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"`
// Deprecated: do not use. It does nothing.
UnusedPubKeys []string `json:"pubKeys,omitempty"`
}
// SSHAction is how to handle an incoming connection.

View File

@@ -556,17 +556,17 @@ func (src *SSHPrincipal) Clone() *SSHPrincipal {
}
dst := new(SSHPrincipal)
*dst = *src
dst.PubKeys = append(src.PubKeys[:0:0], src.PubKeys...)
dst.UnusedPubKeys = append(src.UnusedPubKeys[:0:0], src.UnusedPubKeys...)
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
PubKeys []string
Node StableNodeID
NodeIP string
UserLogin string
Any bool
UnusedPubKeys []string
}{})
// Clone makes a deep copy of ControlDialPlan.

View File

@@ -1260,19 +1260,21 @@ 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) PubKeys() views.Slice[string] { return views.SliceOf(v.ж.PubKeys) }
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)
}
// 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
PubKeys []string
Node StableNodeID
NodeIP string
UserLogin string
Any bool
UnusedPubKeys []string
}{})
// View returns a readonly view of ControlDialPlan.

28
types/bools/bools.go Normal file
View File

@@ -0,0 +1,28 @@
// 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
}
}

View File

@@ -19,3 +19,12 @@ 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)
}
}

View File

@@ -1,17 +0,0 @@
// 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
}
}

23
types/iox/io.go Normal file
View File

@@ -0,0 +1,23 @@
// 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)
}

39
types/iox/io_test.go Normal file
View File

@@ -0,0 +1,39 @@
// 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)
}
}

View File

@@ -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 "", fmt.Errorf("%q is too long to be a DNS name", s)
return "", vizerror.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 "", fmt.Errorf("%q is not a valid DNS label", label)
return "", vizerror.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 errors.New("empty DNS label")
return vizerror.New("empty DNS label")
}
if len(label) > maxLabelLength {
return fmt.Errorf("%q is too long, max length is %d bytes", label, maxLabelLength)
return vizerror.Errorf("%q is too long, max length is %d bytes", label, maxLabelLength)
}
if !isalphanum(label[0]) {
return fmt.Errorf("%q is not a valid DNS label: must start with a letter or number", label)
return vizerror.Errorf("%q is not a valid DNS label: must start with a letter or number", label)
}
if !isalphanum(label[len(label)-1]) {
return fmt.Errorf("%q is not a valid DNS label: must end with a letter or number", label)
return vizerror.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 fmt.Errorf("%q is not a valid DNS label: contains invalid character %q", label, label[i])
return vizerror.Errorf("%q is not a valid DNS label: contains invalid character %q", label, label[i])
}
}
return nil