Compare commits
8 Commits
will/webcl
...
dsnet/http
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e20bd2ffe | ||
|
|
b89c113365 | ||
|
|
ff9c1ebb4a | ||
|
|
5cc1bfe82d | ||
|
|
469af614b0 | ||
|
|
331a6d105f | ||
|
|
6540d1f018 | ||
|
|
ca48db0d60 |
6
Makefile
6
Makefile
@@ -3,6 +3,8 @@ 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 ./...
|
||||
|
||||
@@ -88,7 +90,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} PUSH=true TARGET=client ./build_docker.sh
|
||||
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} 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)
|
||||
@@ -96,7 +98,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} PUSH=true TARGET=operator ./build_docker.sh
|
||||
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
|
||||
|
||||
help: ## Show this help
|
||||
@echo "\nSpecify a command. The choices are:\n"
|
||||
|
||||
@@ -32,6 +32,7 @@ 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)
|
||||
@@ -50,6 +51,7 @@ case "$TARGET" in
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
/usr/local/bin/containerboot
|
||||
;;
|
||||
operator)
|
||||
@@ -65,6 +67,7 @@ case "$TARGET" in
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
/usr/local/bin/operator
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -232,3 +233,55 @@ 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
|
||||
}
|
||||
|
||||
@@ -95,9 +95,16 @@ 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) {
|
||||
setCanConnectOverTS(true) // already connected over ts
|
||||
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)
|
||||
return
|
||||
}
|
||||
// Otherwise, test connection to the ts IP.
|
||||
@@ -111,7 +118,7 @@ function LoginPopoverContent({
|
||||
setIsRunningCheck(false)
|
||||
})
|
||||
.catch(() => setIsRunningCheck(false))
|
||||
}, [auth.viewerIdentity, isRunningCheck, node.IPv4])
|
||||
}, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS])
|
||||
|
||||
/**
|
||||
* Checking connection for first time on page load.
|
||||
@@ -193,6 +200,14 @@ 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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -450,10 +450,11 @@ 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"`
|
||||
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
|
||||
}
|
||||
|
||||
// serverAPIAuth handles requests to the /api/auth endpoint
|
||||
@@ -464,10 +465,16 @@ 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()
|
||||
|
||||
@@ -450,6 +450,7 @@ func TestServeAuth(t *testing.T) {
|
||||
NodeName: remoteNode.Node.Name,
|
||||
NodeIP: remoteIP,
|
||||
ProfilePicURL: user.ProfilePicURL,
|
||||
Capabilities: peerCapabilities{},
|
||||
}
|
||||
|
||||
testControlURL := &defaultControlURL
|
||||
@@ -1097,6 +1098,163 @@ 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"
|
||||
|
||||
@@ -59,8 +59,6 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: ENABLE_CONNECTOR
|
||||
value: "{{ .Values.enableConnector }}"
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
|
||||
@@ -8,10 +8,6 @@ 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.
|
||||
|
||||
@@ -286,8 +286,6 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: ENABLE_CONNECTOR
|
||||
value: "false"
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
|
||||
@@ -62,7 +62,6 @@ 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
|
||||
@@ -93,7 +92,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, tsEnableConnector)
|
||||
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
|
||||
}
|
||||
|
||||
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
|
||||
@@ -201,7 +200,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, enableConnector bool) {
|
||||
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string) {
|
||||
var (
|
||||
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
|
||||
)
|
||||
@@ -222,9 +221,7 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
},
|
||||
},
|
||||
}
|
||||
if enableConnector {
|
||||
mgrOpts.Scheme = tsapi.GlobalScheme
|
||||
Scheme: tsapi.GlobalScheme,
|
||||
}
|
||||
mgr, err := manager.New(restConfig, mgrOpts)
|
||||
if err != nil {
|
||||
@@ -278,22 +275,20 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
startlog.Fatalf("could not create controller: %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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
|
||||
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
|
||||
|
||||
@@ -214,18 +214,19 @@ 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
|
||||
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
|
||||
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 + "-"
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -19,32 +22,20 @@ 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) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1573,6 +1573,17 @@ 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 {
|
||||
|
||||
@@ -1341,6 +1341,9 @@ 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
|
||||
|
||||
81
util/httphdr/auth.go
Normal file
81
util/httphdr/auth.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// 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
|
||||
}
|
||||
43
util/httpio/context.go
Normal file
43
util/httpio/context.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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].
|
||||
93
util/httpio/endpoint.go
Normal file
93
util/httpio/endpoint.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// 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...)
|
||||
}
|
||||
121
util/httpio/httpio.go
Normal file
121
util/httpio/httpio.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// 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")
|
||||
}
|
||||
44
util/httpio/options.go
Normal file
44
util/httpio/options.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// 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")
|
||||
}
|
||||
10
util/httpio/urlpath/urlpath.go
Normal file
10
util/httpio/urlpath/urlpath.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// 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)
|
||||
10
util/httpio/urlquery/urlquery.go
Normal file
10
util/httpio/urlquery/urlquery.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// 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)
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -155,6 +156,13 @@ 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) }
|
||||
@@ -185,10 +193,43 @@ 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("netsh", args...)
|
||||
cmd := exec.Command(ft.getNetshPath(), args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
b, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
19
wgengine/router/router_windows_test.go
Normal file
19
wgengine/router/router_windows_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user