Compare commits

..

1 Commits

Author SHA1 Message Date
Will Norris
1b35416b37 ipn/ipnlocal: allow running webclient on mobile 2024-01-10 09:47:31 -08:00
25 changed files with 76 additions and 801 deletions

View File

@@ -3,8 +3,6 @@ SYNO_ARCH ?= "amd64"
SYNO_DSM ?= "7"
TAGS ?= "latest"
PLATFORM ?= "flyio" ## flyio==linux/amd64. Set to "" to build all platforms.
vet: ## Run go vet
./tool/go vet ./...
@@ -90,7 +88,7 @@ publishdevimage: ## Build and publish tailscale image to location specified by $
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=client ./build_docker.sh
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=client ./build_docker.sh
publishdevoperator: ## Build and publish k8s-operator image to location specified by ${REPO}
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@@ -98,7 +96,7 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=operator ./build_docker.sh
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"

View File

@@ -32,7 +32,6 @@ PUSH="${PUSH:-false}"
TARGET="${TARGET:-${DEFAULT_TARGET}}"
TAGS="${TAGS:-${DEFAULT_TAGS}}"
BASE="${BASE:-${DEFAULT_BASE}}"
PLATFORM="${PLATFORM:-}" # default to all platforms
case "$TARGET" in
client)
@@ -51,7 +50,6 @@ case "$TARGET" in
--tags="${TAGS}" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
/usr/local/bin/containerboot
;;
operator)
@@ -67,7 +65,6 @@ case "$TARGET" in
--tags="${TAGS}" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
/usr/local/bin/operator
;;
*)

View File

@@ -8,7 +8,6 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
@@ -233,55 +232,3 @@ func (s *Server) newSessionID() (string, error) {
}
return "", errors.New("too many collisions generating new session; please refresh page")
}
type peerCapabilities map[capFeature]bool // value is true if the peer can edit the given feature
// canEdit is true if the peerCapabilities grant edit access
// to the given feature.
func (p peerCapabilities) canEdit(feature capFeature) bool {
if p == nil {
return false
}
if p[capFeatureAll] {
return true
}
return p[feature]
}
type capFeature string
const (
// The following values should not be edited.
// New caps can be added, but existing ones should not be changed,
// as these exact values are used by users in tailnet policy files.
capFeatureAll capFeature = "*" // grants peer management of all features
capFeatureFunnel capFeature = "funnel" // grants peer serve/funnel management
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
capFeatureSubnet capFeature = "subnet" // grants peer subnet routes management
capFeatureExitNode capFeature = "exitnode" // grants peer ability to advertise-as and use exit nodes
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
)
type capRule struct {
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
}
// toPeerCapabilities parses out the web ui capabilities from the
// given whois response.
func toPeerCapabilities(whois *apitype.WhoIsResponse) (peerCapabilities, error) {
caps := peerCapabilities{}
if whois == nil {
return caps, nil
}
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
}
for _, c := range rules {
for _, f := range c.CanEdit {
caps[capFeature(strings.ToLower(f))] = true
}
}
return caps, nil
}

View File

@@ -95,16 +95,9 @@ function LoginPopoverContent({
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
// Whether the current page is loaded over HTTPS.
// If it is, then the connectivity check to the management client
// will fail with a mixed-content error.
const isHTTPS = window.location.protocol === "https:"
const checkTSConnection = useCallback(() => {
if (auth.viewerIdentity || isHTTPS) {
// Skip the connectivity check if we either already know we're connected over Tailscale,
// or know the connectivity check will fail because the current page is loaded over HTTPS.
setCanConnectOverTS(true)
if (auth.viewerIdentity) {
setCanConnectOverTS(true) // already connected over ts
return
}
// Otherwise, test connection to the ts IP.
@@ -118,7 +111,7 @@ function LoginPopoverContent({
setIsRunningCheck(false)
})
.catch(() => setIsRunningCheck(false))
}, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS])
}, [auth.viewerIdentity, isRunningCheck, node.IPv4])
/**
* Checking connection for first time on page load.
@@ -200,14 +193,6 @@ function LoginPopoverContent({
You can see most of this device's details. To make changes,
you need to sign in.
</p>
{isHTTPS && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
<p className="text-gray-500 text-xs font-semibold pt-2">
Make sure you are connected to your tailnet, and that your
policy file allows access.
</p>
)}
<SignInButton auth={auth} onClick={handleSignInClick} />
</>
)}

View File

@@ -450,11 +450,10 @@ type authResponse struct {
// viewerIdentity is the Tailscale identity of the source node
// connected to this web client.
type viewerIdentity struct {
LoginName string `json:"loginName"`
NodeName string `json:"nodeName"`
NodeIP string `json:"nodeIP"`
ProfilePicURL string `json:"profilePicUrl,omitempty"`
Capabilities peerCapabilities `json:"capabilities"` // features peer is allowed to edit
LoginName string `json:"loginName"`
NodeName string `json:"nodeName"`
NodeIP string `json:"nodeIP"`
ProfilePicURL string `json:"profilePicUrl,omitempty"`
}
// serverAPIAuth handles requests to the /api/auth endpoint
@@ -465,16 +464,10 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
session, whois, status, sErr := s.getSession(r)
if whois != nil {
caps, err := toPeerCapabilities(whois)
if err != nil {
http.Error(w, sErr.Error(), http.StatusInternalServerError)
return
}
resp.ViewerIdentity = &viewerIdentity{
LoginName: whois.UserProfile.LoginName,
NodeName: whois.Node.Name,
ProfilePicURL: whois.UserProfile.ProfilePicURL,
Capabilities: caps,
}
if addrs := whois.Node.Addresses; len(addrs) > 0 {
resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()

View File

@@ -450,7 +450,6 @@ func TestServeAuth(t *testing.T) {
NodeName: remoteNode.Node.Name,
NodeIP: remoteIP,
ProfilePicURL: user.ProfilePicURL,
Capabilities: peerCapabilities{},
}
testControlURL := &defaultControlURL
@@ -1098,163 +1097,6 @@ func TestRequireTailscaleIP(t *testing.T) {
}
}
func TestPeerCapabilities(t *testing.T) {
// Testing web.toPeerCapabilities
toPeerCapsTests := []struct {
name string
whois *apitype.WhoIsResponse
wantCaps peerCapabilities
}{
{
name: "empty-whois",
whois: nil,
wantCaps: peerCapabilities{},
},
{
name: "no-webui-caps",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
},
},
wantCaps: peerCapabilities{},
},
{
name: "one-webui-cap",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnet: true,
},
},
{
name: "multiple-webui-cap",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
"{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnet: true,
capFeatureExitNode: true,
capFeatureAll: true,
},
},
{
name: "case=insensitive-caps",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"SSH\",\"sUBnet\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnet: true,
},
},
{
name: "random-canEdit-contents-dont-error",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"unknown-feature\"]}",
},
},
},
wantCaps: peerCapabilities{
"unknown-feature": true,
},
},
{
name: "no-canEdit-section",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canDoSomething\":[\"*\"]}",
},
},
},
wantCaps: peerCapabilities{},
},
}
for _, tt := range toPeerCapsTests {
t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
got, err := toPeerCapabilities(tt.whois)
if err != nil {
t.Fatalf("unexpected: %v", err)
}
if diff := cmp.Diff(got, tt.wantCaps); diff != "" {
t.Errorf("wrong caps; (-got+want):%v", diff)
}
})
}
// Testing web.peerCapabilities.canEdit
canEditTests := []struct {
name string
caps peerCapabilities
wantCanEdit map[capFeature]bool
}{
{
name: "empty-caps",
caps: nil,
wantCanEdit: map[capFeature]bool{
capFeatureAll: false,
capFeatureFunnel: false,
capFeatureSSH: false,
capFeatureSubnet: false,
capFeatureExitNode: false,
capFeatureAccount: false,
},
},
{
name: "some-caps",
caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{
capFeatureAll: false,
capFeatureFunnel: false,
capFeatureSSH: true,
capFeatureSubnet: false,
capFeatureExitNode: false,
capFeatureAccount: true,
},
},
{
name: "wildcard-in-caps",
caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{
capFeatureAll: true,
capFeatureFunnel: true,
capFeatureSSH: true,
capFeatureSubnet: true,
capFeatureExitNode: true,
capFeatureAccount: true,
},
},
}
for _, tt := range canEditTests {
t.Run("canEdit-"+tt.name, func(t *testing.T) {
for f, want := range tt.wantCanEdit {
if got := tt.caps.canEdit(f); got != want {
t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want)
}
}
})
}
}
var (
defaultControlURL = "https://controlplane.tailscale.com"
testAuthPath = "/a/12345"

View File

@@ -59,6 +59,8 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: ENABLE_CONNECTOR
value: "{{ .Values.enableConnector }}"
- name: CLIENT_ID_FILE
value: /oauth/client_id
- name: CLIENT_SECRET_FILE

View File

@@ -8,6 +8,10 @@ oauth: {}
# clientId: ""
# clientSecret: ""
# enableConnector determines whether the operator should reconcile
# connector.tailscale.com custom resources.
enableConnector: "false"
# installCRDs determines whether tailscale.com CRDs should be installed as part
# of chart installation. We do not use Helm's CRD installation mechanism as that
# does not allow for upgrading CRDs.

View File

@@ -286,6 +286,8 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: ENABLE_CONNECTOR
value: "false"
- name: CLIENT_ID_FILE
value: /oauth/client_id
- name: CLIENT_SECRET_FILE

View File

@@ -62,6 +62,7 @@ func main() {
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
tsEnableConnector = defaultBool("ENABLE_CONNECTOR", false)
)
var opts []kzap.Opts
@@ -92,7 +93,7 @@ func main() {
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
// TODO (irbekrm): gather the reconciler options into an opts struct
// rather than passing a million of them in one by one.
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode, tsEnableConnector)
}
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
@@ -200,7 +201,7 @@ waitOnline:
// runReconcilers starts the controller-runtime manager and registers the
// ServiceReconciler. It blocks forever.
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string) {
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string, enableConnector bool) {
var (
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
)
@@ -221,7 +222,9 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
&appsv1.StatefulSet{}: nsFilter,
},
},
Scheme: tsapi.GlobalScheme,
}
if enableConnector {
mgrOpts.Scheme = tsapi.GlobalScheme
}
mgr, err := manager.New(restConfig, mgrOpts)
if err != nil {
@@ -275,20 +278,22 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
startlog.Fatalf("could not create controller: %v", err)
}
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("connector"))
err = builder.ControllerManagedBy(mgr).
For(&tsapi.Connector{}).
Watches(&appsv1.StatefulSet{}, connectorFilter).
Watches(&corev1.Secret{}, connectorFilter).
Complete(&ConnectorReconciler{
ssr: ssr,
recorder: eventRecorder,
Client: mgr.GetClient(),
logger: zlog.Named("connector-reconciler"),
clock: tstime.DefaultClock{},
})
if err != nil {
startlog.Fatal("could not create connector reconciler: %v", err)
if enableConnector {
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("subnetrouter"))
err = builder.ControllerManagedBy(mgr).
For(&tsapi.Connector{}).
Watches(&appsv1.StatefulSet{}, connectorFilter).
Watches(&corev1.Secret{}, connectorFilter).
Complete(&ConnectorReconciler{
ssr: ssr,
recorder: eventRecorder,
Client: mgr.GetClient(),
logger: zlog.Named("connector-reconciler"),
clock: tstime.DefaultClock{},
})
if err != nil {
startlog.Fatal("could not create connector reconciler: %v", err)
}
}
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {

View File

@@ -214,19 +214,18 @@ const maxStatefulSetNameLength = 63 - 10 - 1
// generation will NOT result in a StatefulSet name longer than 52 chars.
// This is done because of https://github.com/kubernetes/kubernetes/issues/64023.
func statefulSetNameBase(parent string) string {
base := fmt.Sprintf("ts-%s-", parent)
// Calculate what length name GenerateName returns for this base.
generator := names.SimpleNameGenerator
for {
generatedName := generator.GenerateName(base)
excess := len(generatedName) - maxStatefulSetNameLength
if excess <= 0 {
return base
}
base = base[:len(base)-1-excess] // cut off the excess chars
if !strings.HasSuffix(base, "-") { // dash may have been cut by the generator
base = base + "-"
}
generatedName := generator.GenerateName(base)
if excess := len(generatedName) - maxStatefulSetNameLength; excess > 0 {
base = base[:len(base)-excess-1] // take extra char off to make space for hyphen
base = base + "-" // re-instate hyphen
}
return base
}
func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {

View File

@@ -6,9 +6,6 @@
package main
import (
"fmt"
"regexp"
"strings"
"testing"
)
@@ -22,20 +19,32 @@ import (
// https://github.com/kubernetes/kubernetes/blob/v1.28.4/staging/src/k8s.io/apiserver/pkg/storage/names/generate.go#L45.
// https://github.com/kubernetes/kubernetes/pull/116430
func Test_statefulSetNameBase(t *testing.T) {
// Service name lengths can be 1 - 63 chars, be paranoid and test them all.
var b strings.Builder
for b.Len() < 63 {
if _, err := b.WriteString("a"); err != nil {
t.Fatalf("error writing to string builder: %v", err)
}
baseLength := len(b.String())
if baseLength > 43 {
baseLength = 43 // currently 43 is the max base length
}
wantsNameR := regexp.MustCompile(`^ts-a{` + fmt.Sprint(baseLength) + `}-$`) // to match a string like ts-aaaa-
gotName := statefulSetNameBase(b.String())
if !wantsNameR.MatchString(gotName) {
t.Fatalf("expected string %s to match regex %s ", gotName, wantsNameR.String()) // fatal rather than error as this test is called 63 times
}
tests := []struct {
name string
in string
out string
}{
{
name: "43 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "44 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xbo",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "42 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x-",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := statefulSetNameBase(tt.in); got != tt.out {
t.Errorf("stsNamePrefix(%s) = %q, want %s", tt.in, got, tt.out)
}
})
}
}

View File

@@ -1573,17 +1573,6 @@ func (c *sclient) sendMeshUpdates() error {
c.s.mu.Lock()
defer c.s.mu.Unlock()
// allow all happened-before mesh update request goroutines to complete, if
// we don't finish the task we'll queue another below.
drainUpdates:
for {
select {
case <-c.meshUpdate:
default:
break drainUpdates
}
}
writes := 0
for _, pcs := range c.peerStateChange {
if c.bw.Available() <= frameHeaderLen+keyLen {

View File

@@ -1,8 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android
package ipnlocal
import (

View File

@@ -1,30 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || android
package ipnlocal
import (
"errors"
"net"
"tailscale.com/client/tailscale"
)
const webClientPort = 5252
type webClient struct{}
func (b *LocalBackend) ConfigureWebClient(lc *tailscale.LocalClient) {}
func (b *LocalBackend) webClientGetOrInit() error {
return errors.New("not implemented")
}
func (b *LocalBackend) webClientShutdown() {}
func (b *LocalBackend) handleWebClientConn(c net.Conn) error {
return errors.New("not implemented")
}
func (b *LocalBackend) updateWebClientListenersLocked() {}

View File

@@ -1341,9 +1341,6 @@ const (
PeerCapabilityWakeOnLAN PeerCapability = "https://tailscale.com/cap/wake-on-lan"
// PeerCapabilityIngress grants the ability for a peer to send ingress traffic.
PeerCapabilityIngress PeerCapability = "https://tailscale.com/cap/ingress"
// PeerCapabilityWebUI grants the ability for a peer to edit features from the
// device Web UI.
PeerCapabilityWebUI PeerCapability = "tailscale.com/cap/webui"
)
// NodeCapMap is a map of capabilities to their optional values. It is valid for

View File

@@ -1,81 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package httphdr
import (
"bytes"
"encoding/base64"
"fmt"
"strings"
)
// TODO: Must authorization parameters be valid UTF-8?
// AuthScheme is an authorization scheme per RFC 7235.
// Per section 2.1, the "Authorization" header is formatted as:
//
// Authorization: <auth-scheme> <auth-parameter>
//
// A scheme implementation must self-report the <auth-scheme> name and
// provide the ability to marshal and unmarshal the <auth-parameter>.
//
// For concrete implementations, see [Basic] and [Bearer].
type AuthScheme interface {
// AuthScheme is the authorization scheme name.
// It must be valid according to RFC 7230, section 3.2.6.
AuthScheme() string
// MarshalAuth marshals the authorization parameter for the scheme.
MarshalAuth() (string, error)
// UnmarshalAuth unmarshals the authorization parameter for the scheme.
UnmarshalAuth(string) error
}
// BasicAuth is the Basic authorization scheme as defined in RFC 2617.
type BasicAuth struct {
Username string // must not contain ':' per section 2
Password string
}
func (BasicAuth) AuthScheme() string { return "Basic" }
func (a BasicAuth) MarshalAuth() (string, error) {
if strings.IndexByte(a.Username, ':') >= 0 {
return "", fmt.Errorf("invalid username: contains a colon")
}
return base64.StdEncoding.EncodeToString([]byte(a.Username + ":" + a.Password)), nil
}
func (a *BasicAuth) UnmarshalAuth(s string) error {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return fmt.Errorf("invalid basic authorization: %w", err)
}
i := bytes.IndexByte(b, ':')
if i < 0 {
return fmt.Errorf("invalid basic authorization: missing a colon")
}
a.Username = string(b[:i])
a.Password = string(b[i+len(":"):])
return nil
}
// BearerAuth is the Bearer Token authorization scheme as defined in RFC 6750.
type BearerAuth struct {
Token string // usually a base64-encoded string per section 2.1
}
func (BearerAuth) AuthScheme() string { return "Bearer" }
func (a BearerAuth) MarshalAuth() (string, error) {
// TODO: Verify that token is valid base64?
return a.Token, nil
}
func (a *BearerAuth) UnmarshalAuth(s string) error {
// TODO: Verify that token is valid base64?
a.Token = s
return nil
}

View File

@@ -1,43 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package httpio
import (
"context"
"net/http"
"tailscale.com/util/httphdr"
)
type headerKey struct{}
// WithHeader specifies the HTTP header to use with a client request.
// It only affects [Do], [Get], [Post], [Put], and [Delete].
//
// Example usage:
//
// ctx = httpio.WithHeader(ctx, http.Header{"DD-API-KEY": ...})
func WithHeader(ctx context.Context, hdr http.Header) context.Context {
return context.WithValue(ctx, headerKey{}, hdr)
}
type authKey struct{}
// WithAuth specifies an "Authorization" header to use with a client request.
// This takes precedence over any "Authorization" header that may be present
// in the [http.Header] provided to [WithHeader].
// It only affects [Do], [Get], [Post], [Put], and [Delete].
//
// Example usage:
//
// ctx = httpio.WithAuth(ctx, httphdr.BasicAuth{
// Username: "admin",
// Password: "password",
// })
func WithAuth(ctx context.Context, auth httphdr.AuthScheme) context.Context {
return context.WithValue(ctx, authKey{}, auth)
}
// TODO: Add extraction functionality to retrieve the original
// *http.Request and http.ResponseWriter for use with [Handler].

View File

@@ -1,93 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package httpio
import (
"context"
"strings"
)
// Endpoint annotates an HTTP method and path with input and output types.
//
// The intent is to declare this in a shared package between client and server
// implementations as a means to structurally describe how they interact.
//
// Example usage:
//
// package tsapi
//
// const BaseURL = "https://api.tailscale.com/api/v2/"
//
// var (
// GetDevice = httpio.Endpoint[GetDeviceRequest, GetDeviceResponse]{Method: "GET", Pattern: "/device/{DeviceID}"}.WithHost(BaseURL)
// DeleteDevice = httpio.Endpoint[DeleteDeviceRequest, DeleteDeviceResponse]{Method: "DELETE", Pattern: "/device/{DeviceID}"}.WithHost(BaseURL)
// )
//
// type GetDeviceRequest struct {
// ID int `urlpath:"DeviceID"`
// Fields []string `urlquery:"fields"`
// ...
// }
// type GetDeviceResponse struct {
// ID int `json:"id"`
// Addresses []netip.Addr `json:"addresses"`
// ...
// }
// type DeleteDeviceRequest struct { ... }
// type DeleteDeviceResponse struct { ... }
//
// Example usage by client code:
//
// ctx = httpio.WithAuth(ctx, ...)
// device, err := tsapi.GetDevice.Do(ctx, {ID: 1234})
//
// Example usage by server code:
//
// mux := http.NewServeMux()
// mux.Handle(tsapi.GetDevice.String(), checkAuth(httpio.Handler(getDevice)))
// mux.Handle(tsapi.DeleteDevice.String(), checkAuth(httpio.Handler(deleteDevice)))
//
// func checkAuth(http.Handler) http.Handler { ... }
// func getDevice(ctx context.Context, in GetDeviceRequest) (out GetDeviceResponse, err error) { ... }
// func deleteDevice(ctx context.Context, in DeleteDeviceRequest) (out DeleteDeviceResponse, err error) { ... }
type Endpoint[In Request, Out Response] struct {
// Method is a valid HTTP method (e.g., "GET").
Method string
// Pattern must be a pattern that complies with [mux.ServeMux.Handle] and
// not be preceded by a method or host (e.g., "/api/v2/device/{DeviceID}").
// It must start with a leading "/".
Pattern string
}
// String returns a combination of the method and pattern,
// which is a valid pattern for [mux.ServeMux.Handle].
func (e Endpoint[In, Out]) String() string { return e.Method + " " + e.Pattern }
// Do performs an HTTP call to the target endpoint at the specified host.
// The hostPrefix must be a URL prefix containing the scheme and host,
// but not contain any URL query parameters (e.g., "https://api.tailscale.com/api/v2/").
func (e Endpoint[In, Out]) Do(ctx context.Context, hostPrefix string, in In, opts ...Option) (out Out, err error) {
return Do[In, Out](ctx, e.Method, strings.TrimRight(hostPrefix, "/")+e.Pattern, in, opts...)
}
// TODO: Should hostPrefix be a *url.URL?
// WithHost constructs a [HostedEndpoint],
// which is an HTTP endpoint hosted at a particular URL prefix.
func (e Endpoint[In, Out]) WithHost(hostPrefix string) HostedEndpoint[In, Out] {
return HostedEndpoint[In, Out]{Prefix: hostPrefix, Endpoint: e}
}
// HostedEndpoint is an HTTP endpoint hosted under a particular URL prefix.
type HostedEndpoint[In Request, Out Response] struct {
// Prefix is a URL prefix containing the scheme, host, and
// an optional path prefix (e.g., "https://api.tailscale.com/api/v2/").
Prefix string
Endpoint[In, Out]
}
// Do performs an HTTP call to the target hosted endpoint.
func (e HostedEndpoint[In, Out]) Do(ctx context.Context, in In, opts ...Option) (out Out, err error) {
return Do[In, Out](ctx, e.Method, strings.TrimSuffix(e.Prefix, "/")+e.Pattern, in, opts...)
}

View File

@@ -1,121 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package httpio assists in handling HTTP operations on structured
// input and output types. It automatically handles encoding of data
// in the URL path, URL query parameters, and the HTTP body.
package httpio
import (
"context"
"net/http"
"tailscale.com/util/httpm"
)
// Request is a structured Go type that contains fields representing arguments
// in the URL path, URL query parameters, and optionally the HTTP request body.
//
// Typically, this is a Go struct:
//
// - with fields tagged as `urlpath` to represent arguments in the URL path
// (e.g., "/tailnet/{tailnetId}/devices/{deviceId}").
// See [tailscale.com/util/httpio/urlpath] for details.
//
// - with fields tagged as `urlquery` to represent URL query parameters
// (e.g., "?after=18635&limit=5").
// See [tailscale.com/util/httpio/urlquery] for details.
//
// - with possibly other fields used to serialize as the HTTP body.
// By default, [encoding/json] is used to marshal the entire struct value.
// To prevent fields specific to `urlpath` or `urlquery` from being marshaled
// as part of the body, explicitly ignore those fields with `json:"-"`.
// An HTTP body is only populated if there are any exported fields
// without the `urlpath` or `urlquery` struct tags.
//
// Since GET and DELETE methods usually have no associated body,
// requests for such methods often only have `urlpath` and `urlquery` fields.
//
// Example GET request type:
//
// type GetDevicesRequest struct {
// TailnetID tailcfg.TailnetID `urlpath:"tailnetId"`
//
// Limit uint `urlquery:"limit"`
// After tailcfg.DeviceID `urlquery:"after"`
// }
//
// Example PUT request type:
//
// type PutDeviceRequest struct {
// TailnetID tailcfg.TailnetID `urlpath:"tailnetId" json:"-"`
// DeviceID tailcfg.DeviceID `urlpath:"deviceId" json:"-"`
//
// Hostname string `json:"hostname,omitempty"``
// IPv4 netip.IPAddr `json:"ipv4,omitzero"``
// }
//
// By convention, request struct types are named "{Method}{Resource}Request",
// where {Method} is the HTTP method (e.g., "Post, "Get", "Put", "Delete", etc.)
// and {Resource} is some resource acted upon (e.g., "Device", "Routes", etc.).
type Request = any
// Response is a structured Go type to represent the HTTP response body.
//
// By default, [encoding/json] is used to unmarshal the response value.
// Unlike [Request], there is no support for `urlpath` and `urlquery` struct tags.
//
// Example response type:
//
// type GetDevicesResponses struct {
// Devices []Device `json:"devices"`
// Error ErrorResponse `json:"error"`
// }
//
// By convention, response struct types are named "{Method}{Resource}Response",
// where {Method} is the HTTP method (e.g., "Post, "Get", "Put", "Delete", etc.)
// and {Resource} is some resource acted upon (e.g., "Device", "Routes", etc.).
type Response = any
// Handler wraps a caller-provided handle function that operates on
// concrete input and output types and returns a [http.Handler] function.
func Handler[In Request, Out Response](handle func(ctx context.Context, in In) (out Out, err error), opts ...Option) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: How do we respond to the user if err is non-nil?
// Do we default to status 500?
panic("not implemented")
})
}
// TODO: Should url be a *url.URL? In the usage below, the caller should not pass query parameters.
// Post performs a POST call to the provided url with the given input
// and returns the response output.
func Post[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
return Do[In, Out](ctx, httpm.POST, url, in, opts...)
}
// Get performs a GET call to the provided url with the given input
// and returns the response output.
func Get[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
return Do[In, Out](ctx, httpm.GET, url, in, opts...)
}
// Put performs a PUT call to the provided url with the given input
// and returns the response output.
func Put[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
return Do[In, Out](ctx, httpm.PUT, url, in, opts...)
}
// Delete performs a DELETE call to the provided url with the given input
// and returns the response output.
func Delete[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
return Do[In, Out](ctx, httpm.DELETE, url, in, opts...)
}
// Do performs an HTTP method call to the provided url with the given input
// and returns the response output.
func Do[In Request, Out Response](ctx context.Context, method, url string, in In, opts ...Option) (out Out, err error) {
// TOOD: If the server returned a non-2xx code, we should report a Go error.
panic("not implemented")
}

View File

@@ -1,44 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package httpio
import (
"io"
"net/http"
)
// Option is an option to alter the behavior of [httpio] functionality.
type Option interface{ option() }
// WithClient specifies the [http.Client] to use in client-initiated requests.
// It only affects [Do], [Get], [Post], [Put], and [Delete].
// It has no effect on [Handler].
func WithClient(c *http.Client) Option {
panic("not implemented")
}
// WithMarshaler specifies an marshaler to use for a particular "Content-Type".
//
// For client-side requests (e.g., [Do], [Get], [Post], [Put], and [Delete]),
// the first specified encoder is used to specify the "Content-Type" and
// to marshal the HTTP request body.
//
// For server-side responses (e.g., [Handler]), the first match between
// the client-provided "Accept" header is used to select the encoder to use.
// If no match is found, the first specified encoder is used regardless.
//
// If no encoder is specified, by default the "application/json" content type
// is used with the [encoding/json] as the marshal implementation.
func WithMarshaler(contentType string, marshal func(io.Writer, any) error) Option {
panic("not implemented")
}
// WithUnmarshaler specifies an unmarshaler to use for a particular "Content-Type".
//
// For both client-side responses and server-side requests,
// the provided "Content-Type" header is used to select which decoder to use.
// If no match is found, the first specified encoder is used regardless.
func WithUnmarshaler(contentType string, unmarshal func(io.Reader, any) error) Option {
panic("not implemented")
}

View File

@@ -1,10 +0,0 @@
// Package urpath TODO
package urlpath
// option is an option to alter behavior of Marshal and Unmarshal.
// Currently, there are no defined options.
type option interface{ option() }
func Marshal(pattern string, val any, opts ...option) (path string, err error)
func Unmarshal(pattern, path string, val any, opts ...option) (err error)

View File

@@ -1,10 +0,0 @@
// Package urlquery TODO
package urlquery
// option is an option to alter behavior of Marshal and Unmarshal.
// Currently, there are no defined options.
type option interface{ option() }
func Marshal(val any, opts ...option) (query string, err error)
func Unmarshal(query string, val any, opts ...option) (err error)

View File

@@ -12,7 +12,6 @@ import (
"net/netip"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
@@ -156,13 +155,6 @@ type firewallTweaker struct {
// stop makes fwProc exit when closed.
fwProcWriter io.WriteCloser
fwProcEncoder *json.Encoder
// The path to the 'netsh.exe' binary, populated during the first call
// to runFirewall.
//
// not protected by mu; netshPath is only mutated inside netshPathOnce
netshPathOnce sync.Once
netshPath string
}
func (ft *firewallTweaker) clear() { ft.set(nil, nil, nil) }
@@ -193,43 +185,10 @@ func (ft *firewallTweaker) set(cidrs []string, routes, localRoutes []netip.Prefi
go ft.doAsyncSet()
}
// getNetshPath returns the path that should be used to execute netsh.
//
// We've seen a report from a customer that we're triggering the "cannot run
// executable found relative to current directory" protection that was added to
// prevent running possibly attacker-controlled binaries. To mitigate this,
// first try looking up the path to netsh.exe in the System32 directory
// explicitly, and then fall back to the prior behaviour of passing "netsh" to
// os/exec.Command.
func (ft *firewallTweaker) getNetshPath() string {
ft.netshPathOnce.Do(func() {
// The default value is the old approach: just run "netsh" and
// let os/exec resolve that into a full path.
ft.netshPath = "netsh"
path, err := windows.KnownFolderPath(windows.FOLDERID_System, 0)
if err != nil {
ft.logf("getNetshPath: error getting FOLDERID_System: %v", err)
return
}
expath := filepath.Join(path, "netsh.exe")
if _, err := os.Stat(expath); err == nil {
ft.netshPath = expath
return
} else if !os.IsNotExist(err) {
ft.logf("getNetshPath: error checking for existence of %q: %v", expath, err)
}
// Keep default
})
return ft.netshPath
}
func (ft *firewallTweaker) runFirewall(args ...string) (time.Duration, error) {
t0 := time.Now()
args = append([]string{"advfirewall", "firewall"}, args...)
cmd := exec.Command(ft.getNetshPath(), args...)
cmd := exec.Command("netsh", args...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
b, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -1,19 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package router
import (
"path/filepath"
"testing"
)
func TestGetNetshPath(t *testing.T) {
ft := &firewallTweaker{
logf: t.Logf,
}
path := ft.getNetshPath()
if !filepath.IsAbs(path) {
t.Errorf("expected absolute path for netsh.exe: %q", path)
}
}