Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfccc5a49 | ||
|
|
1528c94a79 | ||
|
|
5bbff79412 | ||
|
|
3a3fc3e8ac | ||
|
|
2e70eef65c | ||
|
|
c0ca41daeb | ||
|
|
c2e1e9e68e | ||
|
|
510f0745ea | ||
|
|
44570dabda | ||
|
|
744e386d26 | ||
|
|
da517a04d2 | ||
|
|
9065c7fb00 | ||
|
|
6f97d4b26b | ||
|
|
d08c776848 | ||
|
|
d4222fae95 | ||
|
|
45da3a4b28 | ||
|
|
ec8feb6aad | ||
|
|
7fdcab4dec | ||
|
|
cc326ea820 | ||
|
|
0e39594ef1 | ||
|
|
b1d8872d10 | ||
|
|
406bcadc65 | ||
|
|
1a45fae577 | ||
|
|
0fec73082a | ||
|
|
e1f6f6f8c2 | ||
|
|
a4b4656879 | ||
|
|
522124c5a7 | ||
|
|
cf45d98caa | ||
|
|
1f9b619588 | ||
|
|
2b4594c3ff | ||
|
|
43138c7a5c | ||
|
|
b0626ff84c | ||
|
|
634cc2ba4a | ||
|
|
d09e9d967f | ||
|
|
0ffc7bf38b | ||
|
|
49de23cf1b | ||
|
|
84c8860472 | ||
|
|
ddbc950f46 | ||
|
|
6985369479 | ||
|
|
3477bfd234 | ||
|
|
3f626c0d77 | ||
|
|
45354dab9b | ||
|
|
b4f46c31bb | ||
|
|
532b26145a | ||
|
|
e1e22785b4 | ||
|
|
f81348a16b | ||
|
|
540e4c83d0 | ||
|
|
2a2228f97b | ||
|
|
2cc1100d24 | ||
|
|
2336c340c4 | ||
|
|
1103044598 | ||
|
|
856ea2376b | ||
|
|
aecb0ab76b | ||
|
|
0f9a054cba | ||
|
|
9545e36007 | ||
|
|
38af62c7b3 | ||
|
|
11e96760ff | ||
|
|
94fa6d97c5 | ||
|
|
0d76d7d21c | ||
|
|
c0a1ed86cb | ||
|
|
41aac26106 | ||
|
|
5d07c17b93 | ||
|
|
9d1348fe21 | ||
|
|
853fe3b713 | ||
|
|
6ab39b7bcd | ||
|
|
e815ae0ec4 | ||
|
|
7fe6e50858 | ||
|
|
212270463b | ||
|
|
b2665d9b89 | ||
|
|
ae5bc88ebe | ||
|
|
85241f8408 | ||
|
|
d4d21a0bbf | ||
|
|
0f4c9c0ecb | ||
|
|
f8f53bb6d4 | ||
|
|
72587ab03c | ||
|
|
c76a6e5167 | ||
|
|
fd77965f23 | ||
|
|
e711ee5d22 | ||
|
|
877fa504b4 | ||
|
|
874db2173b | ||
|
|
bb60da2764 | ||
|
|
18fc093c0d | ||
|
|
c0a9895748 | ||
|
|
fa95318a47 | ||
|
|
22c89fcb19 | ||
|
|
d32d742af0 | ||
|
|
6a885dbc36 | ||
|
|
74dd24ce71 | ||
|
|
ff5f233c3a | ||
|
|
2aa9125ac4 | ||
|
|
5f22f72636 | ||
|
|
a8f9c0d6e4 | ||
|
|
e0d711c478 | ||
|
|
40c991f6b8 | ||
|
|
adc8368964 | ||
|
|
12e6094d9c | ||
|
|
ecc8035f73 | ||
|
|
f07ff47922 | ||
|
|
c2144c44a3 | ||
|
|
e7545f2eac | ||
|
|
17335d2104 | ||
|
|
f9949cde8b | ||
|
|
33029d4486 | ||
|
|
acb4a22dcc |
@@ -1 +1 @@
|
||||
1.75.0
|
||||
1.77.0
|
||||
|
||||
@@ -56,6 +56,7 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
@@ -72,6 +73,7 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/tkatype"
|
||||
"tailscale.com/util/syspolicy/setting"
|
||||
)
|
||||
|
||||
// defaultLocalClient is the default LocalClient when using the legacy
|
||||
@@ -814,6 +815,33 @@ func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn
|
||||
return decodeJSON[*ipn.Prefs](body)
|
||||
}
|
||||
|
||||
// GetEffectivePolicy returns the effective policy for the specified scope.
|
||||
func (lc *LocalClient) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
|
||||
scopeID, err := scope.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := lc.get200(ctx, "/localapi/v0/policy/"+string(scopeID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*setting.Snapshot](body)
|
||||
}
|
||||
|
||||
// ReloadEffectivePolicy reloads the effective policy for the specified scope
|
||||
// by reading and merging policy settings from all applicable policy sources.
|
||||
func (lc *LocalClient) ReloadEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
|
||||
scopeID, err := scope.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/policy/"+string(scopeID), 200, http.NoBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*setting.Snapshot](body)
|
||||
}
|
||||
|
||||
// GetDNSOSConfig returns the system DNS configuration for the current device.
|
||||
// That is, it returns the DNS configuration that the system would use if Tailscale weren't being used.
|
||||
func (lc *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
|
||||
|
||||
@@ -51,6 +51,9 @@ type Client struct {
|
||||
// HTTPClient optionally specifies an alternate HTTP client to use.
|
||||
// If nil, http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
|
||||
// UserAgent optionally specifies an alternate User-Agent header
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
@@ -97,8 +100,9 @@ func (c *Client) setAuth(r *http.Request) {
|
||||
// and can be changed manually by the user.
|
||||
func NewClient(tailnet string, auth AuthMethod) *Client {
|
||||
return &Client{
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
UserAgent: "tailscale-client-oss",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,17 +114,16 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
if c.UserAgent != "" {
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
}
|
||||
return c.httpClient().Do(req)
|
||||
}
|
||||
|
||||
// sendRequest add the authentication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.httpClient().Do(req)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/envknob/featureknob"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
@@ -960,37 +961,16 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func availableFeatures() map[string]bool {
|
||||
env := hostinfo.GetEnvType()
|
||||
features := map[string]bool{
|
||||
"advertise-exit-node": true, // available on all platforms
|
||||
"advertise-routes": true, // available on all platforms
|
||||
"use-exit-node": canUseExitNode(env) == nil,
|
||||
"ssh": envknob.CanRunTailscaleSSH() == nil,
|
||||
"use-exit-node": featureknob.CanUseExitNode() == nil,
|
||||
"ssh": featureknob.CanRunTailscaleSSH() == nil,
|
||||
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
|
||||
}
|
||||
if env == hostinfo.HomeAssistantAddOn {
|
||||
// Setting SSH on Home Assistant causes trouble on startup
|
||||
// (since the flag is not being passed to `tailscale up`).
|
||||
// Although Tailscale SSH does work here,
|
||||
// it's not terribly useful since it's running in a separate container.
|
||||
features["ssh"] = false
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
func canUseExitNode(env hostinfo.EnvType) error {
|
||||
switch dist := distro.Get(); dist {
|
||||
case distro.Synology, // see https://github.com/tailscale/tailscale/issues/1995
|
||||
distro.QNAP,
|
||||
distro.Unraid:
|
||||
return fmt.Errorf("Tailscale exit nodes cannot be used on %s.", dist)
|
||||
}
|
||||
if env == hostinfo.HomeAssistantAddOn {
|
||||
return errors.New("Tailscale exit nodes cannot be used on Home Assistant.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// aclsAllowAccess returns whether tailnet ACLs (as expressed in the provided filter rules)
|
||||
// permit any devices to access the local web client.
|
||||
// This does not currently check whether a specific device can connect, just any device.
|
||||
|
||||
@@ -113,6 +113,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/net/stunserver from tailscale.com/cmd/derper
|
||||
L tailscale.com/net/tcpinfo from tailscale.com/derp
|
||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/tsaddr from tailscale.com/ipn+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/wsconn from tailscale.com/cmd/derper+
|
||||
@@ -163,11 +164,16 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
|
||||
tailscale.com/util/syspolicy from tailscale.com/ipn
|
||||
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
|
||||
tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
|
||||
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/testenv from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/usermetric from tailscale.com/health
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
W 💣 tailscale.com/util/winutil/gp from tailscale.com/util/syspolicy/source
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
tailscale.com/version/distro from tailscale.com/envknob+
|
||||
@@ -188,7 +194,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
|
||||
W golang.org/x/exp/constraints from tailscale.com/util/winutil
|
||||
golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting
|
||||
golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting+
|
||||
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
||||
golang.org/x/net/dns/dnsmessage from net+
|
||||
golang.org/x/net/http/httpguts from net/http
|
||||
@@ -249,7 +255,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
encoding/pem from crypto/tls+
|
||||
errors from bufio+
|
||||
expvar from github.com/prometheus/client_golang/prometheus+
|
||||
flag from tailscale.com/cmd/derper
|
||||
flag from tailscale.com/cmd/derper+
|
||||
fmt from compress/flate+
|
||||
go/token from google.golang.org/protobuf/internal/strs
|
||||
hash from crypto+
|
||||
@@ -283,7 +289,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
os from crypto/rand+
|
||||
os/exec from github.com/coreos/go-iptables/iptables+
|
||||
os/signal from tailscale.com/cmd/derper
|
||||
W os/user from tailscale.com/util/winutil
|
||||
W os/user from tailscale.com/util/winutil+
|
||||
path from github.com/prometheus/client_golang/prometheus/internal+
|
||||
path/filepath from crypto/x509+
|
||||
reflect from crypto/x509+
|
||||
|
||||
@@ -75,6 +75,11 @@ func main() {
|
||||
prober.WithPageLink("Prober metrics", "/debug/varz"),
|
||||
prober.WithProbeLink("Run Probe", "/debug/probe-run?name={{.Name}}"),
|
||||
), tsweb.HandlerOptions{Logf: log.Printf}))
|
||||
mux.Handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok\n"))
|
||||
}))
|
||||
log.Printf("Listening on %s", *listen)
|
||||
log.Fatal(http.ListenAndServe(*listen, mux))
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ func main() {
|
||||
|
||||
ctx := context.Background()
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.UserAgent = "tailscale-get-authkey"
|
||||
tsClient.HTTPClient = credentials.Client(ctx)
|
||||
tsClient.BaseURL = baseURL
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/ip from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/multicast from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/net/tstun+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+
|
||||
💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
|
||||
@@ -668,6 +668,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/doctor/routetable from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/drive from tailscale.com/client/tailscale+
|
||||
tailscale.com/envknob from tailscale.com/client/tailscale+
|
||||
tailscale.com/envknob/featureknob from tailscale.com/client/web+
|
||||
tailscale.com/health from tailscale.com/control/controlclient+
|
||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||
@@ -734,6 +735,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
||||
L tailscale.com/net/tcpinfo from tailscale.com/derp
|
||||
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/tsaddr from tailscale.com/client/web+
|
||||
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+
|
||||
@@ -810,8 +812,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/util/slicesx from tailscale.com/appc+
|
||||
tailscale.com/util/syspolicy from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
|
||||
tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
|
||||
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/systemd from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/testenv from tailscale.com/control/controlclient+
|
||||
@@ -821,7 +826,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns
|
||||
W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns+
|
||||
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
|
||||
tailscale.com/util/zstdframe from tailscale.com/control/controlclient+
|
||||
|
||||
@@ -1896,6 +1896,182 @@ spec:
|
||||
Value is the taint value the toleration matches to.
|
||||
If the operator is Exists, the value should be empty, otherwise just a regular string.
|
||||
type: string
|
||||
topologySpreadConstraints:
|
||||
description: |-
|
||||
Proxy Pod's topology spread constraints.
|
||||
By default Tailscale Kubernetes operator does not apply any topology spread constraints.
|
||||
https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/
|
||||
type: array
|
||||
items:
|
||||
description: TopologySpreadConstraint specifies how to spread matching pods among the given topology.
|
||||
type: object
|
||||
required:
|
||||
- maxSkew
|
||||
- topologyKey
|
||||
- whenUnsatisfiable
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
LabelSelector is used to find matching pods.
|
||||
Pods that match this label selector are counted to determine the number of pods
|
||||
in their corresponding topology domain.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select the pods over which
|
||||
spreading will be calculated. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are ANDed with labelSelector
|
||||
to select the group of existing pods over which spreading will be calculated
|
||||
for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector.
|
||||
MatchLabelKeys cannot be set when LabelSelector isn't set.
|
||||
Keys that don't exist in the incoming pod labels will
|
||||
be ignored. A null or empty list means only match against labelSelector.
|
||||
|
||||
This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default).
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
maxSkew:
|
||||
description: |-
|
||||
MaxSkew describes the degree to which pods may be unevenly distributed.
|
||||
When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference
|
||||
between the number of matching pods in the target topology and the global minimum.
|
||||
The global minimum is the minimum number of matching pods in an eligible domain
|
||||
or zero if the number of eligible domains is less than MinDomains.
|
||||
For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same
|
||||
labelSelector spread as 2/2/1:
|
||||
In this case, the global minimum is 1.
|
||||
| zone1 | zone2 | zone3 |
|
||||
| P P | P P | P |
|
||||
- if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2;
|
||||
scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2)
|
||||
violate MaxSkew(1).
|
||||
- if MaxSkew is 2, incoming pod can be scheduled onto any zone.
|
||||
When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence
|
||||
to topologies that satisfy it.
|
||||
It's a required field. Default value is 1 and 0 is not allowed.
|
||||
type: integer
|
||||
format: int32
|
||||
minDomains:
|
||||
description: |-
|
||||
MinDomains indicates a minimum number of eligible domains.
|
||||
When the number of eligible domains with matching topology keys is less than minDomains,
|
||||
Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed.
|
||||
And when the number of eligible domains with matching topology keys equals or greater than minDomains,
|
||||
this value has no effect on scheduling.
|
||||
As a result, when the number of eligible domains is less than minDomains,
|
||||
scheduler won't schedule more than maxSkew Pods to those domains.
|
||||
If value is nil, the constraint behaves as if MinDomains is equal to 1.
|
||||
Valid values are integers greater than 0.
|
||||
When value is not nil, WhenUnsatisfiable must be DoNotSchedule.
|
||||
|
||||
For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same
|
||||
labelSelector spread as 2/2/2:
|
||||
| zone1 | zone2 | zone3 |
|
||||
| P P | P P | P P |
|
||||
The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0.
|
||||
In this situation, new pod with the same labelSelector cannot be scheduled,
|
||||
because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones,
|
||||
it will violate MaxSkew.
|
||||
type: integer
|
||||
format: int32
|
||||
nodeAffinityPolicy:
|
||||
description: |-
|
||||
NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector
|
||||
when calculating pod topology spread skew. Options are:
|
||||
- Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations.
|
||||
- Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations.
|
||||
|
||||
If this value is nil, the behavior is equivalent to the Honor policy.
|
||||
This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag.
|
||||
type: string
|
||||
nodeTaintsPolicy:
|
||||
description: |-
|
||||
NodeTaintsPolicy indicates how we will treat node taints when calculating
|
||||
pod topology spread skew. Options are:
|
||||
- Honor: nodes without taints, along with tainted nodes for which the incoming pod
|
||||
has a toleration, are included.
|
||||
- Ignore: node taints are ignored. All nodes are included.
|
||||
|
||||
If this value is nil, the behavior is equivalent to the Ignore policy.
|
||||
This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag.
|
||||
type: string
|
||||
topologyKey:
|
||||
description: |-
|
||||
TopologyKey is the key of node labels. Nodes that have a label with this key
|
||||
and identical values are considered to be in the same topology.
|
||||
We consider each <key, value> as a "bucket", and try to put balanced number
|
||||
of pods into each bucket.
|
||||
We define a domain as a particular instance of a topology.
|
||||
Also, we define an eligible domain as a domain whose nodes meet the requirements of
|
||||
nodeAffinityPolicy and nodeTaintsPolicy.
|
||||
e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology.
|
||||
And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology.
|
||||
It's a required field.
|
||||
type: string
|
||||
whenUnsatisfiable:
|
||||
description: |-
|
||||
WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy
|
||||
the spread constraint.
|
||||
- DoNotSchedule (default) tells the scheduler not to schedule it.
|
||||
- ScheduleAnyway tells the scheduler to schedule the pod in any location,
|
||||
but giving higher precedence to topologies that would help reduce the
|
||||
skew.
|
||||
A constraint is considered "Unsatisfiable" for an incoming pod
|
||||
if and only if every possible node assignment for that pod would violate
|
||||
"MaxSkew" on some topology.
|
||||
For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same
|
||||
labelSelector spread as 3/1/1:
|
||||
| zone1 | zone2 | zone3 |
|
||||
| P P P | P | P |
|
||||
If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled
|
||||
to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies
|
||||
MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler
|
||||
won't make it *more* imbalanced.
|
||||
It's a required field.
|
||||
type: string
|
||||
tailscale:
|
||||
description: |-
|
||||
TailscaleConfig contains options to configure the tailscale-specific
|
||||
|
||||
@@ -2323,6 +2323,182 @@ spec:
|
||||
type: string
|
||||
type: object
|
||||
type: array
|
||||
topologySpreadConstraints:
|
||||
description: |-
|
||||
Proxy Pod's topology spread constraints.
|
||||
By default Tailscale Kubernetes operator does not apply any topology spread constraints.
|
||||
https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/
|
||||
items:
|
||||
description: TopologySpreadConstraint specifies how to spread matching pods among the given topology.
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
LabelSelector is used to find matching pods.
|
||||
Pods that match this label selector are counted to determine the number of pods
|
||||
in their corresponding topology domain.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select the pods over which
|
||||
spreading will be calculated. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are ANDed with labelSelector
|
||||
to select the group of existing pods over which spreading will be calculated
|
||||
for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector.
|
||||
MatchLabelKeys cannot be set when LabelSelector isn't set.
|
||||
Keys that don't exist in the incoming pod labels will
|
||||
be ignored. A null or empty list means only match against labelSelector.
|
||||
|
||||
This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default).
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
maxSkew:
|
||||
description: |-
|
||||
MaxSkew describes the degree to which pods may be unevenly distributed.
|
||||
When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference
|
||||
between the number of matching pods in the target topology and the global minimum.
|
||||
The global minimum is the minimum number of matching pods in an eligible domain
|
||||
or zero if the number of eligible domains is less than MinDomains.
|
||||
For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same
|
||||
labelSelector spread as 2/2/1:
|
||||
In this case, the global minimum is 1.
|
||||
| zone1 | zone2 | zone3 |
|
||||
| P P | P P | P |
|
||||
- if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2;
|
||||
scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2)
|
||||
violate MaxSkew(1).
|
||||
- if MaxSkew is 2, incoming pod can be scheduled onto any zone.
|
||||
When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence
|
||||
to topologies that satisfy it.
|
||||
It's a required field. Default value is 1 and 0 is not allowed.
|
||||
format: int32
|
||||
type: integer
|
||||
minDomains:
|
||||
description: |-
|
||||
MinDomains indicates a minimum number of eligible domains.
|
||||
When the number of eligible domains with matching topology keys is less than minDomains,
|
||||
Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed.
|
||||
And when the number of eligible domains with matching topology keys equals or greater than minDomains,
|
||||
this value has no effect on scheduling.
|
||||
As a result, when the number of eligible domains is less than minDomains,
|
||||
scheduler won't schedule more than maxSkew Pods to those domains.
|
||||
If value is nil, the constraint behaves as if MinDomains is equal to 1.
|
||||
Valid values are integers greater than 0.
|
||||
When value is not nil, WhenUnsatisfiable must be DoNotSchedule.
|
||||
|
||||
For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same
|
||||
labelSelector spread as 2/2/2:
|
||||
| zone1 | zone2 | zone3 |
|
||||
| P P | P P | P P |
|
||||
The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0.
|
||||
In this situation, new pod with the same labelSelector cannot be scheduled,
|
||||
because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones,
|
||||
it will violate MaxSkew.
|
||||
format: int32
|
||||
type: integer
|
||||
nodeAffinityPolicy:
|
||||
description: |-
|
||||
NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector
|
||||
when calculating pod topology spread skew. Options are:
|
||||
- Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations.
|
||||
- Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations.
|
||||
|
||||
If this value is nil, the behavior is equivalent to the Honor policy.
|
||||
This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag.
|
||||
type: string
|
||||
nodeTaintsPolicy:
|
||||
description: |-
|
||||
NodeTaintsPolicy indicates how we will treat node taints when calculating
|
||||
pod topology spread skew. Options are:
|
||||
- Honor: nodes without taints, along with tainted nodes for which the incoming pod
|
||||
has a toleration, are included.
|
||||
- Ignore: node taints are ignored. All nodes are included.
|
||||
|
||||
If this value is nil, the behavior is equivalent to the Ignore policy.
|
||||
This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag.
|
||||
type: string
|
||||
topologyKey:
|
||||
description: |-
|
||||
TopologyKey is the key of node labels. Nodes that have a label with this key
|
||||
and identical values are considered to be in the same topology.
|
||||
We consider each <key, value> as a "bucket", and try to put balanced number
|
||||
of pods into each bucket.
|
||||
We define a domain as a particular instance of a topology.
|
||||
Also, we define an eligible domain as a domain whose nodes meet the requirements of
|
||||
nodeAffinityPolicy and nodeTaintsPolicy.
|
||||
e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology.
|
||||
And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology.
|
||||
It's a required field.
|
||||
type: string
|
||||
whenUnsatisfiable:
|
||||
description: |-
|
||||
WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy
|
||||
the spread constraint.
|
||||
- DoNotSchedule (default) tells the scheduler not to schedule it.
|
||||
- ScheduleAnyway tells the scheduler to schedule the pod in any location,
|
||||
but giving higher precedence to topologies that would help reduce the
|
||||
skew.
|
||||
A constraint is considered "Unsatisfiable" for an incoming pod
|
||||
if and only if every possible node assignment for that pod would violate
|
||||
"MaxSkew" on some topology.
|
||||
For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same
|
||||
labelSelector spread as 3/1/1:
|
||||
| zone1 | zone2 | zone3 |
|
||||
| P P P | P | P |
|
||||
If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled
|
||||
to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies
|
||||
MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler
|
||||
won't make it *more* imbalanced.
|
||||
It's a required field.
|
||||
type: string
|
||||
required:
|
||||
- maxSkew
|
||||
- topologyKey
|
||||
- whenUnsatisfiable
|
||||
type: object
|
||||
type: array
|
||||
type: object
|
||||
type: object
|
||||
tailscale:
|
||||
|
||||
@@ -143,6 +143,7 @@ func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) {
|
||||
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
|
||||
}
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.UserAgent = "tailscale-k8s-operator"
|
||||
tsClient.HTTPClient = credentials.Client(context.Background())
|
||||
|
||||
s := &tsnet.Server{
|
||||
|
||||
@@ -432,6 +432,148 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
}
|
||||
|
||||
func TestTailnetTargetIPAnnotation_IPCouldNotBeParsed(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
recorder: record.NewFakeRecorder(100),
|
||||
}
|
||||
tailnetTargetIP := "invalid-ip"
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
t0 := conditionTime(clock)
|
||||
|
||||
want := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
LastTransitionTime: t0,
|
||||
Reason: reasonProxyInvalid,
|
||||
Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-ip: "invalid-ip" could not be parsed as a valid IP Address, error: ParseAddr("invalid-ip"): unable to parse IP`,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestTailnetTargetIPAnnotation_InvalidIP(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
recorder: record.NewFakeRecorder(100),
|
||||
}
|
||||
tailnetTargetIP := "999.999.999.999"
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
t0 := conditionTime(clock)
|
||||
|
||||
want := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
LastTransitionTime: t0,
|
||||
Reason: reasonProxyInvalid,
|
||||
Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-ip: "999.999.999.999" could not be parsed as a valid IP Address, error: ParseAddr("999.999.999.999"): IPv4 field has value >255`,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestAnnotations(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
|
||||
@@ -718,6 +718,7 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet,
|
||||
ss.Spec.Template.Spec.NodeSelector = wantsPod.NodeSelector
|
||||
ss.Spec.Template.Spec.Affinity = wantsPod.Affinity
|
||||
ss.Spec.Template.Spec.Tolerations = wantsPod.Tolerations
|
||||
ss.Spec.Template.Spec.TopologySpreadConstraints = wantsPod.TopologySpreadConstraints
|
||||
|
||||
// Update containers.
|
||||
updateContainer := func(overlay *tsapi.Container, base corev1.Container) corev1.Container {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/yaml"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/types/ptr"
|
||||
@@ -73,6 +74,16 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
NodeSelector: map[string]string{"beta.kubernetes.io/os": "linux"},
|
||||
Affinity: &corev1.Affinity{NodeAffinity: &corev1.NodeAffinity{RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{}}},
|
||||
Tolerations: []corev1.Toleration{{Key: "", Operator: "Exists"}},
|
||||
TopologySpreadConstraints: []corev1.TopologySpreadConstraint{
|
||||
{
|
||||
WhenUnsatisfiable: "DoNotSchedule",
|
||||
TopologyKey: "kubernetes.io/hostname",
|
||||
MaxSkew: 3,
|
||||
LabelSelector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"foo": "bar"},
|
||||
},
|
||||
},
|
||||
},
|
||||
TailscaleContainer: &tsapi.Container{
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Privileged: ptr.To(true),
|
||||
@@ -159,6 +170,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
wantSS.Spec.Template.Spec.NodeSelector = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeSelector
|
||||
wantSS.Spec.Template.Spec.Affinity = proxyClassAllOpts.Spec.StatefulSet.Pod.Affinity
|
||||
wantSS.Spec.Template.Spec.Tolerations = proxyClassAllOpts.Spec.StatefulSet.Pod.Tolerations
|
||||
wantSS.Spec.Template.Spec.TopologySpreadConstraints = proxyClassAllOpts.Spec.StatefulSet.Pod.TopologySpreadConstraints
|
||||
wantSS.Spec.Template.Spec.Containers[0].SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.SecurityContext
|
||||
wantSS.Spec.Template.Spec.InitContainers[0].SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleInitContainer.SecurityContext
|
||||
wantSS.Spec.Template.Spec.Containers[0].Resources = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.Resources
|
||||
@@ -201,6 +213,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
wantSS.Spec.Template.Spec.NodeSelector = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeSelector
|
||||
wantSS.Spec.Template.Spec.Affinity = proxyClassAllOpts.Spec.StatefulSet.Pod.Affinity
|
||||
wantSS.Spec.Template.Spec.Tolerations = proxyClassAllOpts.Spec.StatefulSet.Pod.Tolerations
|
||||
wantSS.Spec.Template.Spec.TopologySpreadConstraints = proxyClassAllOpts.Spec.StatefulSet.Pod.TopologySpreadConstraints
|
||||
wantSS.Spec.Template.Spec.Containers[0].SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.SecurityContext
|
||||
wantSS.Spec.Template.Spec.Containers[0].Resources = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.Resources
|
||||
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env, []corev1.EnvVar{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}}...)
|
||||
|
||||
@@ -358,9 +358,14 @@ func validateService(svc *corev1.Service) []string {
|
||||
violations = append(violations, fmt.Sprintf("invalid value of annotation %s: %q does not appear to be a valid MagicDNS name", AnnotationTailnetTargetFQDN, fqdn))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(irbekrm): validate that tailscale.com/tailnet-ip annotation is a
|
||||
// valid IP address (tailscale/tailscale#13671).
|
||||
if ipStr := svc.Annotations[AnnotationTailnetTargetIP]; ipStr != "" {
|
||||
ip, err := netip.ParseAddr(ipStr)
|
||||
if err != nil {
|
||||
violations = append(violations, fmt.Sprintf("invalid value of annotation %s: %q could not be parsed as a valid IP Address, error: %s", AnnotationTailnetTargetIP, ipStr, err))
|
||||
} else if !ip.IsValid() {
|
||||
violations = append(violations, fmt.Sprintf("parsed IP address in annotation %s: %q is not valid", AnnotationTailnetTargetIP, ipStr))
|
||||
}
|
||||
}
|
||||
|
||||
svcName := nameForService(svc)
|
||||
if err := dnsname.ValidLabel(svcName); err != nil {
|
||||
|
||||
39
cmd/lopower/README.md
Normal file
39
cmd/lopower/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Tailscale LOPOWER
|
||||
|
||||
"Little Opinionated Proxy Over Wireguard-encrypted Routes"
|
||||
|
||||
**STATUS**: in-development alpha (as of 2024-11-03)
|
||||
|
||||
## Background
|
||||
|
||||
Some small devices such as ESP32 microcontrollers [support WireGuard](https://github.com/ciniml/WireGuard-ESP32-Arduino) but are too small to run Tailscale.
|
||||
|
||||
Tailscale LOPOWER is a proxy that you run nearby that bridges a low-power WireGuard-speaking device on one side to Tailscale on the other side. That way network traffic from the low-powered device never hits the network unencrypted but is still able to communicate to/from other Tailscale devices on your Tailnet.
|
||||
|
||||
## Diagram
|
||||
|
||||
<img src="./lopower.svg">
|
||||
|
||||
## Features
|
||||
|
||||
* Runs separate Wireguard server with separate keys (unknown to the Tailscale control plane) that proxy on to Tailscale
|
||||
* Outputs WireGuard-standard configuration to enrolls devices, including in QR code form.
|
||||
* embeds `tsnet`, with an identity on which the device(s) behind the proxy appear on your Tailnet
|
||||
* optional IPv4 support. IPv6 is always enabled, as it never conflicts with anything. But IPv4 (or CGNAT) might already be in use on your client's network.
|
||||
* includes a DNS server (at `fd7a:115c:a1e0:9909::1` by default and optionally also at `10.90.0.1`) to serve both MagicDNS names as well as forwarding non-Tailscale DNS names onwards
|
||||
* if IPv4 is disabled, MagicDNS `A` records are filtered out, and only `AAAA` records are served.
|
||||
|
||||
## Limitations
|
||||
|
||||
* this runs in userspace using gVisor's netstack. That means it's portable (and doesn't require kernel/system configuration), but that does mean it doesn't operate at a packet level but rather it stitches together two separate TCP (or UDP) flows and doesn't support IP protocols such as SCTP or other things that aren't TCP or UDP.
|
||||
* the standard WireGuard configuration doesn't support specifying DNS search domains, so resolving bare names like the `go` in `http://go/foo` won't work and you need to resolve names using the fully qualified `go.your-tailnet.ts.net` names.
|
||||
* since it's based on userspace tsnet mode, it doesn't pick up your system DNS configuration (yet?) and instead resolves non-tailnet DNS names using either your "Override DNS" tailnet settings for the global DNS resolver, or else defaults to `8.8.8.8` and `1.1.1.1` (using DoH) if that isn't set.
|
||||
|
||||
## TODO
|
||||
|
||||
* provisioning more than one low-powered device is possible, but requires manual config file edits. It should be possible to enroll multiple devices (including QR code support) easily.
|
||||
* incoming connections (from Tailscale to `lopower`) don't yet forward to the low-powered devices. When there's only one low-powered device, the mapping policy is obvious. When there are multiple, it's not as obvious. Maybe the answer is supporting [4via6 subnet routers](https://tailscale.com/kb/1201/4via6-subnets).
|
||||
|
||||
## Installing
|
||||
|
||||
* git clone this repo, switch to `lp` branch, `go install ./cmd/lopower` and see `lopower --help`.
|
||||
872
cmd/lopower/lopower.go
Normal file
872
cmd/lopower/lopower.go
Normal file
@@ -0,0 +1,872 @@
|
||||
// The lopower server is a "Little Opinionated Proxy Over
|
||||
// Wireguard-Encrypted Route". It bridges a static WireGuard
|
||||
// client into a Tailscale network.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
"github.com/tailscale/wireguard-go/conn"
|
||||
"github.com/tailscale/wireguard-go/device"
|
||||
"github.com/tailscale/wireguard-go/tun"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"golang.org/x/sys/unix"
|
||||
"gvisor.dev/gvisor/pkg/buffer"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/link/channel"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
|
||||
"gvisor.dev/gvisor/pkg/waiter"
|
||||
"tailscale.com/net/packet"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/wgengine/wgcfg"
|
||||
)
|
||||
|
||||
var (
|
||||
wgListenPort = flag.Int("wg-port", 51820, "port number to listen on for WireGuard from the client")
|
||||
confDir = flag.String("dir", filepath.Join(os.Getenv("HOME"), ".config/lopower"), "directory to store configuration in")
|
||||
wgPubHost = flag.String("wg-host", "0.0.0.1", "IP address of lopower's WireGuard server that's accessible from the client")
|
||||
qrListenAddr = flag.String("qr-listen", "127.0.0.1:8014", "HTTP address to serve a QR code for client's WireGuard configuration, or empty for none")
|
||||
printConfig = flag.Bool("print-config", true, "print the client's WireGuard configuration to stdout on startup")
|
||||
includeV4 = flag.Bool("include-v4", true, "include IPv4 (CGNAT) in the WireGuard configuration; incompatible with some carriers. IPv6 is always included.")
|
||||
verbosePackets = flag.Bool("verbose-packets", false, "log packet contents")
|
||||
)
|
||||
|
||||
type config struct {
|
||||
PrivKey key.NodePrivate // the proxy server's key
|
||||
Peers []Peer
|
||||
|
||||
// V4 and V6 are the local IPs.
|
||||
V4 netip.Addr
|
||||
V6 netip.Addr
|
||||
|
||||
// CIDRs are used to allocate IPs to peers.
|
||||
V4CIDR netip.Prefix
|
||||
V6CIDR netip.Prefix
|
||||
}
|
||||
|
||||
// IsLocalIP reports whether ip is one of the local IPs.
|
||||
func (c *config) IsLocalIP(ip netip.Addr) bool {
|
||||
return ip.IsValid() && (ip == c.V4 || ip == c.V6)
|
||||
}
|
||||
|
||||
type Peer struct {
|
||||
PrivKey key.NodePrivate // e.g. proxy client's
|
||||
V4 netip.Addr
|
||||
V6 netip.Addr
|
||||
}
|
||||
|
||||
func (lp *lpServer) storeConfigLocked() {
|
||||
path := filepath.Join(lp.dir, "config.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
log.Fatalf("os.MkdirAll(%q): %v", filepath.Dir(path), err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
log.Fatalf("os.OpenFile(%q): %v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
must.Do(json.NewEncoder(f).Encode(lp.c))
|
||||
if err := f.Close(); err != nil {
|
||||
log.Fatalf("f.Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (lp *lpServer) loadConfig() {
|
||||
path := filepath.Join(lp.dir, "config.json")
|
||||
f, err := os.Open(path)
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
var cfg *config
|
||||
must.Do(json.NewDecoder(f).Decode(&cfg))
|
||||
if len(cfg.Peers) > 0 { // as early version didn't set this
|
||||
lp.mu.Lock()
|
||||
defer lp.mu.Unlock()
|
||||
lp.c = cfg
|
||||
}
|
||||
return
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
log.Fatalf("os.OpenFile(%q): %v", path, err)
|
||||
}
|
||||
const defaultV4CIDR = "10.90.0.0/24"
|
||||
const defaultV6CIDR = "fd7a:115c:a1e0:9909::/64" // 9909 = above QWERTY "LOPO"(wer)
|
||||
c := &config{
|
||||
PrivKey: key.NewNode(),
|
||||
V4CIDR: netip.MustParsePrefix(defaultV4CIDR),
|
||||
V6CIDR: netip.MustParsePrefix(defaultV6CIDR),
|
||||
}
|
||||
c.V4 = c.V4CIDR.Addr().Next()
|
||||
c.V6 = c.V6CIDR.Addr().Next()
|
||||
c.Peers = append(c.Peers, Peer{
|
||||
PrivKey: key.NewNode(),
|
||||
V4: c.V4.Next(),
|
||||
V6: c.V6.Next(),
|
||||
})
|
||||
|
||||
lp.mu.Lock()
|
||||
defer lp.mu.Unlock()
|
||||
lp.c = c
|
||||
lp.storeConfigLocked()
|
||||
return
|
||||
}
|
||||
|
||||
func (lp *lpServer) reconfig() {
|
||||
lp.mu.Lock()
|
||||
wc := &wgcfg.Config{
|
||||
Name: "lopower0",
|
||||
PrivateKey: lp.c.PrivKey,
|
||||
ListenPort: uint16(*wgListenPort),
|
||||
Addresses: []netip.Prefix{
|
||||
netip.PrefixFrom(lp.c.V4, 32),
|
||||
netip.PrefixFrom(lp.c.V6, 128),
|
||||
},
|
||||
}
|
||||
for _, p := range lp.c.Peers {
|
||||
wc.Peers = append(wc.Peers, wgcfg.Peer{
|
||||
PublicKey: p.PrivKey.Public(),
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.PrefixFrom(p.V4, 32),
|
||||
netip.PrefixFrom(p.V6, 128),
|
||||
},
|
||||
})
|
||||
}
|
||||
lp.mu.Unlock()
|
||||
must.Do(wgcfg.ReconfigDevice(lp.d, wc, log.Printf))
|
||||
}
|
||||
|
||||
func newLP(ctx context.Context) *lpServer {
|
||||
logf := log.Printf
|
||||
deviceLogger := &device.Logger{
|
||||
Verbosef: logger.Discard,
|
||||
Errorf: logf,
|
||||
}
|
||||
lp := &lpServer{
|
||||
ctx: ctx,
|
||||
dir: *confDir,
|
||||
readCh: make(chan *stack.PacketBuffer, 16),
|
||||
}
|
||||
lp.loadConfig()
|
||||
lp.initNetstack(ctx)
|
||||
nst := &nsTUN{
|
||||
lp: lp,
|
||||
closeCh: make(chan struct{}),
|
||||
evChan: make(chan tun.Event),
|
||||
}
|
||||
|
||||
wgdev := wgcfg.NewDevice(nst, conn.NewDefaultBind(), deviceLogger)
|
||||
lp.d = wgdev
|
||||
must.Do(wgdev.Up())
|
||||
lp.reconfig()
|
||||
|
||||
if *printConfig {
|
||||
log.Printf("Device Wireguard config is:\n%s", lp.wgConfigForQR())
|
||||
}
|
||||
|
||||
lp.startTSNet(ctx)
|
||||
return lp
|
||||
}
|
||||
|
||||
type lpServer struct {
|
||||
dir string
|
||||
tsnet *tsnet.Server
|
||||
d *device.Device
|
||||
ns *stack.Stack
|
||||
ctx context.Context // canceled on shutdown
|
||||
linkEP *channel.Endpoint
|
||||
readCh chan *stack.PacketBuffer // from gvisor/dns server => out to network
|
||||
|
||||
// protocolConns tracks the number of active connections for each connection.
|
||||
// It is used to add and remove protocol addresses from netstack as needed.
|
||||
protocolConns syncs.Map[tcpip.ProtocolAddress, *atomic.Int32]
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
c *config
|
||||
}
|
||||
|
||||
// MaxPacketSize is the maximum size (in bytes)
|
||||
// of a packet that can be injected into lpServer.
|
||||
const MaxPacketSize = device.MaxContentSize
|
||||
const nicID = 1
|
||||
|
||||
func (lp *lpServer) initNetstack(ctx context.Context) error {
|
||||
ns := stack.New(stack.Options{
|
||||
NetworkProtocols: []stack.NetworkProtocolFactory{
|
||||
ipv4.NewProtocol,
|
||||
ipv6.NewProtocol,
|
||||
},
|
||||
TransportProtocols: []stack.TransportProtocolFactory{
|
||||
tcp.NewProtocol,
|
||||
icmp.NewProtocol4,
|
||||
udp.NewProtocol,
|
||||
},
|
||||
})
|
||||
lp.ns = ns
|
||||
sackEnabledOpt := tcpip.TCPSACKEnabled(true) // TCP SACK is disabled by default
|
||||
if tcpipErr := ns.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); tcpipErr != nil {
|
||||
return fmt.Errorf("SetTransportProtocolOption SACK: %v", tcpipErr)
|
||||
}
|
||||
lp.linkEP = channel.New(512, 1280, "")
|
||||
if tcpipProblem := ns.CreateNIC(nicID, lp.linkEP); tcpipProblem != nil {
|
||||
return fmt.Errorf("CreateNIC: %v", tcpipProblem)
|
||||
}
|
||||
ns.SetPromiscuousMode(nicID, true)
|
||||
|
||||
lp.mu.Lock()
|
||||
v4, v6 := lp.c.V4, lp.c.V6
|
||||
lp.mu.Unlock()
|
||||
prefix := tcpip.AddrFrom4Slice(v4.AsSlice()).WithPrefix()
|
||||
if *includeV4 {
|
||||
if tcpProb := ns.AddProtocolAddress(nicID, tcpip.ProtocolAddress{
|
||||
Protocol: ipv4.ProtocolNumber,
|
||||
AddressWithPrefix: prefix,
|
||||
}, stack.AddressProperties{}); tcpProb != nil {
|
||||
return errors.New(tcpProb.String())
|
||||
}
|
||||
}
|
||||
prefix = tcpip.AddrFrom16Slice(v6.AsSlice()).WithPrefix()
|
||||
if tcpProb := ns.AddProtocolAddress(nicID, tcpip.ProtocolAddress{
|
||||
Protocol: ipv6.ProtocolNumber,
|
||||
AddressWithPrefix: prefix,
|
||||
}, stack.AddressProperties{}); tcpProb != nil {
|
||||
return errors.New(tcpProb.String())
|
||||
}
|
||||
|
||||
ipv4Subnet, err := tcpip.NewSubnet(tcpip.AddrFromSlice(make([]byte, 4)), tcpip.MaskFromBytes(make([]byte, 4)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create IPv4 subnet: %v", err)
|
||||
}
|
||||
ipv6Subnet, err := tcpip.NewSubnet(tcpip.AddrFromSlice(make([]byte, 16)), tcpip.MaskFromBytes(make([]byte, 16)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create IPv6 subnet: %v", err)
|
||||
}
|
||||
|
||||
routes := []tcpip.Route{{
|
||||
Destination: ipv4Subnet,
|
||||
NIC: nicID,
|
||||
}, {
|
||||
Destination: ipv6Subnet,
|
||||
NIC: nicID,
|
||||
}}
|
||||
if !*includeV4 {
|
||||
routes = routes[1:]
|
||||
}
|
||||
|
||||
ns.SetRouteTable(routes)
|
||||
|
||||
const tcpReceiveBufferSize = 0 // default
|
||||
const maxInFlightConnectionAttempts = 8192
|
||||
tcpFwd := tcp.NewForwarder(ns, tcpReceiveBufferSize, maxInFlightConnectionAttempts, lp.acceptTCP)
|
||||
udpFwd := udp.NewForwarder(ns, lp.acceptUDP)
|
||||
ns.SetTransportProtocolHandler(tcp.ProtocolNumber, func(tei stack.TransportEndpointID, pb *stack.PacketBuffer) (handled bool) {
|
||||
return tcpFwd.HandlePacket(tei, pb)
|
||||
})
|
||||
ns.SetTransportProtocolHandler(udp.ProtocolNumber, func(tei stack.TransportEndpointID, pb *stack.PacketBuffer) (handled bool) {
|
||||
return udpFwd.HandlePacket(tei, pb)
|
||||
})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
pkt := lp.linkEP.ReadContext(ctx)
|
||||
if pkt == nil {
|
||||
if ctx.Err() != nil {
|
||||
// Return without logging.
|
||||
log.Printf("linkEP.ReadContext: %v", ctx.Err())
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
size := pkt.Size()
|
||||
if size > MaxPacketSize || size == 0 {
|
||||
pkt.DecRef()
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case lp.readCh <- pkt:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func netaddrIPFromNetstackIP(s tcpip.Address) netip.Addr {
|
||||
switch s.Len() {
|
||||
case 4:
|
||||
return netip.AddrFrom4(s.As4())
|
||||
case 16:
|
||||
return netip.AddrFrom16(s.As16()).Unmap()
|
||||
}
|
||||
return netip.Addr{}
|
||||
}
|
||||
|
||||
func (lp *lpServer) trackProtocolAddr(destIP netip.Addr) (untrack func()) {
|
||||
pa := tcpip.ProtocolAddress{
|
||||
AddressWithPrefix: tcpip.AddrFromSlice(destIP.AsSlice()).WithPrefix(),
|
||||
}
|
||||
if destIP.Is4() {
|
||||
pa.Protocol = ipv4.ProtocolNumber
|
||||
} else if destIP.Is6() {
|
||||
pa.Protocol = ipv6.ProtocolNumber
|
||||
}
|
||||
|
||||
addrConns, _ := lp.protocolConns.LoadOrInit(pa, func() *atomic.Int32 { return new(atomic.Int32) })
|
||||
if addrConns.Add(1) == 1 {
|
||||
lp.ns.AddProtocolAddress(nicID, pa, stack.AddressProperties{
|
||||
PEB: stack.CanBePrimaryEndpoint, // zero value default
|
||||
ConfigType: stack.AddressConfigStatic, // zero value default
|
||||
})
|
||||
}
|
||||
return func() {
|
||||
if addrConns.Add(-1) == 0 {
|
||||
lp.ns.RemoveAddress(nicID, pa.AddressWithPrefix.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (lp *lpServer) acceptUDP(r *udp.ForwarderRequest) {
|
||||
log.Printf("acceptUDP: %v", r.ID())
|
||||
destIP := netaddrIPFromNetstackIP(r.ID().LocalAddress)
|
||||
untrack := lp.trackProtocolAddr(destIP)
|
||||
var wq waiter.Queue
|
||||
ep, udpErr := r.CreateEndpoint(&wq)
|
||||
if udpErr != nil {
|
||||
log.Printf("CreateEndpoint: %v", udpErr)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer untrack()
|
||||
defer ep.Close()
|
||||
reqDetails := r.ID()
|
||||
|
||||
clientRemoteIP := netaddrIPFromNetstackIP(reqDetails.RemoteAddress)
|
||||
destPort := reqDetails.LocalPort
|
||||
if !clientRemoteIP.IsValid() {
|
||||
log.Printf("acceptUDP: invalid remote IP %v", reqDetails.RemoteAddress)
|
||||
return
|
||||
}
|
||||
|
||||
randPort := rand.IntN(65536-1024) + 1024
|
||||
v4, v6 := lp.tsnet.TailscaleIPs()
|
||||
var listenAddr netip.Addr
|
||||
if destIP.Is4() {
|
||||
listenAddr = v4
|
||||
} else {
|
||||
listenAddr = v6
|
||||
}
|
||||
backendConn, err := lp.tsnet.ListenPacket("udp", fmt.Sprintf("%s:%d", listenAddr, randPort))
|
||||
if err != nil {
|
||||
log.Printf("ListenPacket: %v", err)
|
||||
return
|
||||
}
|
||||
defer backendConn.Close()
|
||||
clientConn := gonet.NewUDPConn(&wq, ep)
|
||||
defer clientConn.Close()
|
||||
errCh := make(chan error, 2)
|
||||
go func() (err error) {
|
||||
defer func() { errCh <- err }()
|
||||
var buf [64]byte
|
||||
for {
|
||||
n, _, err := backendConn.ReadFrom(buf[:])
|
||||
if err != nil {
|
||||
log.Printf("UDP read: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = clientConn.Write(buf[:n])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}()
|
||||
dstAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", destIP, destPort))
|
||||
if err != nil {
|
||||
log.Printf("ResolveUDPAddr: %v", err)
|
||||
return
|
||||
}
|
||||
go func() (err error) {
|
||||
defer func() { errCh <- err }()
|
||||
var buf [2048]byte
|
||||
for {
|
||||
n, err := clientConn.Read(buf[:])
|
||||
if err != nil {
|
||||
log.Printf("UDP read: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = backendConn.WriteTo(buf[:n], dstAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}()
|
||||
err = <-errCh
|
||||
if err != nil {
|
||||
log.Printf("io.Copy: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (lp *lpServer) acceptTCP(r *tcp.ForwarderRequest) {
|
||||
log.Printf("acceptTCP: %v", r.ID())
|
||||
reqDetails := r.ID()
|
||||
destIP := netaddrIPFromNetstackIP(reqDetails.LocalAddress)
|
||||
clientRemoteIP := netaddrIPFromNetstackIP(reqDetails.RemoteAddress)
|
||||
destPort := reqDetails.LocalPort
|
||||
if !clientRemoteIP.IsValid() {
|
||||
log.Printf("acceptTCP: invalid remote IP %v", reqDetails.RemoteAddress)
|
||||
r.Complete(true) // sends a RST
|
||||
return
|
||||
}
|
||||
untrack := lp.trackProtocolAddr(destIP)
|
||||
defer untrack()
|
||||
|
||||
var wq waiter.Queue
|
||||
ep, tcpErr := r.CreateEndpoint(&wq)
|
||||
if tcpErr != nil {
|
||||
log.Printf("CreateEndpoint: %v", tcpErr)
|
||||
r.Complete(true)
|
||||
return
|
||||
}
|
||||
defer ep.Close()
|
||||
ep.SocketOptions().SetKeepAlive(true)
|
||||
|
||||
if destPort == 53 && lp.c.IsLocalIP(destIP) {
|
||||
tc := gonet.NewTCPConn(&wq, ep)
|
||||
defer tc.Close()
|
||||
r.Complete(false) // accept TCP connection
|
||||
lp.handleTCPDNSQuery(tc, netip.AddrPortFrom(clientRemoteIP, reqDetails.RemotePort))
|
||||
return
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
c, err := lp.tsnet.Dial(dialCtx, "tcp", fmt.Sprintf("%s:%d", destIP, destPort))
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("Dial(%s:%d): %v", destIP, destPort, err)
|
||||
r.Complete(true) // sends a RST
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
tc := gonet.NewTCPConn(&wq, ep)
|
||||
defer tc.Close()
|
||||
r.Complete(false) // accept TCP connection
|
||||
|
||||
errc := make(chan error, 2)
|
||||
go func() { _, err := io.Copy(tc, c); errc <- err }()
|
||||
go func() { _, err := io.Copy(c, tc); errc <- err }()
|
||||
err = <-errc
|
||||
if err != nil {
|
||||
log.Printf("io.Copy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (lp *lpServer) wgConfigForQR() string {
|
||||
var b strings.Builder
|
||||
|
||||
p := lp.c.Peers[0]
|
||||
privHex, _ := p.PrivKey.MarshalText()
|
||||
privHex = bytes.TrimPrefix(privHex, []byte("privkey:"))
|
||||
priv := make([]byte, 32)
|
||||
got, err := hex.Decode(priv, privHex)
|
||||
if err != nil || got != 32 {
|
||||
log.Printf("marshal text was: %q", privHex)
|
||||
log.Fatalf("bad private key: %v, % bytes", err, got)
|
||||
}
|
||||
privb64 := base64.StdEncoding.EncodeToString(priv)
|
||||
|
||||
fmt.Fprintf(&b, "[Interface]\nPrivateKey = %s\n", privb64)
|
||||
fmt.Fprintf(&b, "Address = %v,%v\n", p.V6, p.V4)
|
||||
|
||||
pubBin, _ := lp.c.PrivKey.Public().MarshalBinary()
|
||||
if len(pubBin) != 34 {
|
||||
log.Fatalf("bad pubkey length: %d", len(pubBin))
|
||||
}
|
||||
pubBin = pubBin[2:] // trim off "np"
|
||||
pubb64 := base64.StdEncoding.EncodeToString(pubBin)
|
||||
|
||||
fmt.Fprintf(&b, "\n[Peer]\nPublicKey = %v\n", pubb64)
|
||||
if *includeV4 {
|
||||
fmt.Fprintf(&b, "AllowedIPs = %v/32,%v/128,%v,%v\n", lp.c.V4, lp.c.V6, tsaddr.TailscaleULARange(), tsaddr.CGNATRange())
|
||||
} else {
|
||||
fmt.Fprintf(&b, "AllowedIPs = %v/128,%v\n", lp.c.V6, tsaddr.TailscaleULARange())
|
||||
}
|
||||
fmt.Fprintf(&b, "Endpoint = %v\n", net.JoinHostPort(*wgPubHost, fmt.Sprint(*wgListenPort)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (lp *lpServer) serveQR() {
|
||||
ln, err := net.Listen("tcp", *qrListenAddr)
|
||||
if err != nil {
|
||||
log.Fatalf("qr: %v", err)
|
||||
}
|
||||
log.Printf("# Serving QR code at http://%s/", ln.Addr())
|
||||
hs := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
conf := lp.wgConfigForQR()
|
||||
v, err := qrcode.Encode(conf, qrcode.Medium, 512)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(v)
|
||||
}),
|
||||
}
|
||||
if err := hs.Serve(ln); err != nil {
|
||||
log.Fatalf("qr: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type nsTUN struct {
|
||||
lp *lpServer
|
||||
closeCh chan struct{}
|
||||
evChan chan tun.Event
|
||||
}
|
||||
|
||||
func (t *nsTUN) File() *os.File {
|
||||
panic("nsTUN.File() called, which makes no sense")
|
||||
}
|
||||
|
||||
func (t *nsTUN) Close() error {
|
||||
close(t.closeCh)
|
||||
close(t.evChan)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read reads packets from gvisor (or the DNS server) to send out to the network.
|
||||
func (t *nsTUN) Read(out [][]byte, sizes []int, offset int) (int, error) {
|
||||
select {
|
||||
case <-t.closeCh:
|
||||
return 0, io.EOF
|
||||
case resPacket := <-t.lp.readCh:
|
||||
defer resPacket.DecRef()
|
||||
pkt := out[0][offset:]
|
||||
n := copy(pkt, resPacket.NetworkHeader().Slice())
|
||||
n += copy(pkt[n:], resPacket.TransportHeader().Slice())
|
||||
n += copy(pkt[n:], resPacket.Data().AsRange().ToSlice())
|
||||
if *verbosePackets {
|
||||
log.Printf("[v] nsTUN.Read (out): % 02x", pkt[:n])
|
||||
}
|
||||
sizes[0] = n
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Write accepts incoming packets. The packets begin at buffs[:][offset:],
|
||||
// like wireguard-go/tun.Device.Write. Write is called per-peer via
|
||||
// wireguard-go/device.Peer.RoutineSequentialReceiver, so it MUST be
|
||||
// thread-safe.
|
||||
func (t *nsTUN) Write(buffs [][]byte, offset int) (int, error) {
|
||||
var pkt packet.Parsed
|
||||
for _, buff := range buffs {
|
||||
raw := buff[offset:]
|
||||
pkt.Decode(raw)
|
||||
packetBuf := stack.NewPacketBuffer(stack.PacketBufferOptions{
|
||||
Payload: buffer.MakeWithData(slices.Clone(raw)),
|
||||
})
|
||||
if *verbosePackets {
|
||||
log.Printf("[v] nsTUN.Write (in): % 02x", raw)
|
||||
}
|
||||
if pkt.IPProto == ipproto.UDP && pkt.Dst.Port() == 53 && t.lp.c.IsLocalIP(pkt.Dst.Addr()) {
|
||||
// Handle DNS queries before sending to gvisor.
|
||||
t.lp.handleDNSUDPQuery(raw)
|
||||
continue
|
||||
}
|
||||
if pkt.IPVersion == 4 {
|
||||
t.lp.linkEP.InjectInbound(ipv4.ProtocolNumber, packetBuf)
|
||||
} else if pkt.IPVersion == 6 {
|
||||
t.lp.linkEP.InjectInbound(ipv6.ProtocolNumber, packetBuf)
|
||||
}
|
||||
}
|
||||
return len(buffs), nil
|
||||
}
|
||||
|
||||
func (t *nsTUN) Flush() error { return nil }
|
||||
func (t *nsTUN) MTU() (int, error) { return 1500, nil }
|
||||
func (t *nsTUN) Name() (string, error) { return "nstun", nil }
|
||||
func (t *nsTUN) Events() <-chan tun.Event { return t.evChan }
|
||||
func (t *nsTUN) BatchSize() int { return 1 }
|
||||
|
||||
func (lp *lpServer) startTSNet(ctx context.Context) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ts := &tsnet.Server{
|
||||
Dir: filepath.Join(lp.dir, "tsnet"),
|
||||
Hostname: hostname,
|
||||
UserLogf: log.Printf,
|
||||
Ephemeral: false,
|
||||
}
|
||||
lp.tsnet = ts
|
||||
ts.PreStart = func() error {
|
||||
dnsMgr := ts.Sys().DNSManager.Get()
|
||||
dnsMgr.SetForceAAAA(true)
|
||||
|
||||
// Force fallback resolvers to Google and Cloudflare as an ultimate
|
||||
// fallback in case the Tailnet DNS servers are not set/forced. Normally
|
||||
// tailscaled would resort to using the OS DNS resolvers, but
|
||||
// tsnet/userspace binaries don't do that (yet?), so this is the
|
||||
// "Opionated" part of the "LOPOWER" name. The opinion is just using
|
||||
// big providers known to work. (Normally stock tailscaled never
|
||||
// makes such opinions and never defaults to any big provider, unless
|
||||
// you're already running on that big provider's network so have
|
||||
// already indicated you're fine with them.))
|
||||
dnsMgr.SetForceFallbackResolvers([]*dnstype.Resolver{
|
||||
{Addr: "8.8.8.8"},
|
||||
{Addr: "1.1.1.1"},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := ts.Up(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// filteredDNSQuery wraps the MagicDNS server response but filters out A record responses
|
||||
// for *.ts.net if IPv4 is not enabled. This is so the e.g. a phone on a CGNAT-using
|
||||
// network doesn't prefer the "A" record over AAAA when dialing and dial into the
|
||||
// the carrier's CGNAT range into of the AAAA record into the Tailscale IPv6 ULA range.
|
||||
func (lp *lpServer) filteredDNSQuery(ctx context.Context, q []byte, family string, from netip.AddrPort) ([]byte, error) {
|
||||
m, ok := lp.tsnet.Sys().DNSManager.GetOK()
|
||||
if !ok {
|
||||
return nil, errors.New("DNSManager not ready")
|
||||
}
|
||||
origRes, err := m.Query(ctx, q, family, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *includeV4 {
|
||||
return origRes, nil
|
||||
}
|
||||
|
||||
// Filter out *.ts.net A records.
|
||||
|
||||
var msg dnsmessage.Message
|
||||
if err := msg.Unpack(origRes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newAnswers := msg.Answers[:0]
|
||||
for _, a := range msg.Answers {
|
||||
name := a.Header.Name.String()
|
||||
if a.Header.Type == dnsmessage.TypeA && strings.HasSuffix(name, ".ts.net.") {
|
||||
// Drop.
|
||||
continue
|
||||
}
|
||||
newAnswers = append(newAnswers, a)
|
||||
}
|
||||
|
||||
if len(newAnswers) == len(msg.Answers) {
|
||||
// Nothing was filtered. No need to reencode it.
|
||||
return origRes, nil
|
||||
}
|
||||
|
||||
msg.Answers = newAnswers
|
||||
return msg.Pack()
|
||||
}
|
||||
|
||||
func (lp *lpServer) handleTCPDNSQuery(c net.Conn, src netip.AddrPort) {
|
||||
defer c.Close()
|
||||
var lenBuf [2]byte
|
||||
for {
|
||||
c.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
_, err := io.ReadFull(c, lenBuf[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
n := binary.BigEndian.Uint16(lenBuf[:])
|
||||
buf := make([]byte, n)
|
||||
c.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
_, err = io.ReadFull(c, buf[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res, err := lp.filteredDNSQuery(context.Background(), buf, "tcp", src)
|
||||
if err != nil {
|
||||
log.Printf("TCP DNS query error: %v", err)
|
||||
return
|
||||
}
|
||||
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(res)))
|
||||
c.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
_, err = c.Write(lenBuf[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
_, err = c.Write(res)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// caller owns the raw memory.
|
||||
func (lp *lpServer) handleDNSUDPQuery(raw []byte) {
|
||||
var pkt packet.Parsed
|
||||
pkt.Decode(raw)
|
||||
if pkt.IPProto != ipproto.UDP || pkt.Dst.Port() != 53 || !lp.c.IsLocalIP(pkt.Dst.Addr()) {
|
||||
panic("caller error")
|
||||
}
|
||||
|
||||
dnsRes, err := lp.filteredDNSQuery(context.Background(), pkt.Payload(), "udp", pkt.Src)
|
||||
if err != nil {
|
||||
log.Printf("DNS query error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ipLayer := mkIPLayer(layers.IPProtocolUDP, pkt.Dst.Addr(), pkt.Src.Addr())
|
||||
udpLayer := &layers.UDP{
|
||||
SrcPort: 53,
|
||||
DstPort: layers.UDPPort(pkt.Src.Port()),
|
||||
}
|
||||
|
||||
resPkt, err := mkPacket(ipLayer, udpLayer, gopacket.Payload(dnsRes))
|
||||
if err != nil {
|
||||
log.Printf("mkPacket: %v", err)
|
||||
return
|
||||
}
|
||||
pktBuf := stack.NewPacketBuffer(stack.PacketBufferOptions{
|
||||
Payload: buffer.MakeWithData(resPkt),
|
||||
})
|
||||
select {
|
||||
case lp.readCh <- pktBuf:
|
||||
case <-lp.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
type serializableNetworkLayer interface {
|
||||
gopacket.SerializableLayer
|
||||
gopacket.NetworkLayer
|
||||
}
|
||||
|
||||
func mkIPLayer(proto layers.IPProtocol, src, dst netip.Addr) serializableNetworkLayer {
|
||||
if src.Is4() {
|
||||
return &layers.IPv4{
|
||||
Protocol: proto,
|
||||
SrcIP: src.AsSlice(),
|
||||
DstIP: dst.AsSlice(),
|
||||
}
|
||||
}
|
||||
if src.Is6() {
|
||||
return &layers.IPv6{
|
||||
NextHeader: proto,
|
||||
SrcIP: src.AsSlice(),
|
||||
DstIP: dst.AsSlice(),
|
||||
}
|
||||
}
|
||||
panic("invalid src IP")
|
||||
}
|
||||
|
||||
// mkPacket is a serializes a number of layers into a packet.
|
||||
//
|
||||
// It's a convenience wrapper around gopacket.SerializeLayers
|
||||
// that does some things automatically:
|
||||
//
|
||||
// * layers.IPv4/IPv6 Version is set to 4/6 if not already set
|
||||
// * layers.IPv4/IPv6 TTL/HopLimit is set to 64 if not already set
|
||||
// * the TCP/UDP/ICMPv6 checksum is set based on the network layer
|
||||
//
|
||||
// The provided layers in ll must be sorted from lowest (e.g. *layers.Ethernet)
|
||||
// to highest. (Depending on the need, the first layer will be either *layers.Ethernet
|
||||
// or *layers.IPv4/IPv6).
|
||||
func mkPacket(ll ...gopacket.SerializableLayer) ([]byte, error) {
|
||||
var nl gopacket.NetworkLayer
|
||||
for _, la := range ll {
|
||||
switch la := la.(type) {
|
||||
case *layers.IPv4:
|
||||
nl = la
|
||||
if la.Version == 0 {
|
||||
la.Version = 4
|
||||
}
|
||||
if la.TTL == 0 {
|
||||
la.TTL = 64
|
||||
}
|
||||
case *layers.IPv6:
|
||||
nl = la
|
||||
if la.Version == 0 {
|
||||
la.Version = 6
|
||||
}
|
||||
if la.HopLimit == 0 {
|
||||
la.HopLimit = 64
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, la := range ll {
|
||||
switch la := la.(type) {
|
||||
case *layers.TCP:
|
||||
la.SetNetworkLayerForChecksum(nl)
|
||||
case *layers.UDP:
|
||||
la.SetNetworkLayerForChecksum(nl)
|
||||
case *layers.ICMPv6:
|
||||
la.SetNetworkLayerForChecksum(nl)
|
||||
}
|
||||
}
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true}
|
||||
if err := gopacket.SerializeLayers(buf, opts, ll...); err != nil {
|
||||
return nil, fmt.Errorf("serializing packet: %v", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.Printf("lopower starting")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
lp := newLP(ctx)
|
||||
|
||||
if *qrListenAddr != "" {
|
||||
go lp.serveQR()
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, unix.SIGTERM, os.Interrupt)
|
||||
<-sigCh
|
||||
}
|
||||
1
cmd/lopower/lopower.svg
Normal file
1
cmd/lopower/lopower.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 114 KiB |
78
cmd/tailscale/cli/advertise.go
Normal file
78
cmd/tailscale/cli/advertise.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
var advertiseArgs struct {
|
||||
services string // comma-separated list of services to advertise
|
||||
}
|
||||
|
||||
// TODO(naman): This flag may move to set.go or serve_v2.go after the WIPCode
|
||||
// envknob is not needed.
|
||||
var advertiseCmd = &ffcli.Command{
|
||||
Name: "advertise",
|
||||
ShortUsage: "tailscale advertise --services=<services>",
|
||||
ShortHelp: "Advertise this node as a destination for a service",
|
||||
Exec: runAdvertise,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("advertise")
|
||||
fs.StringVar(&advertiseArgs.services, "services", "", "comma-separated services to advertise; each must start with \"svc:\" (e.g. \"svc:idp,svc:nas,svc:database\")")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
|
||||
func maybeAdvertiseCmd() []*ffcli.Command {
|
||||
if !envknob.UseWIPCode() {
|
||||
return nil
|
||||
}
|
||||
return []*ffcli.Command{advertiseCmd}
|
||||
}
|
||||
|
||||
func runAdvertise(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
services, err := parseServiceNames(advertiseArgs.services)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
AdvertiseServicesSet: true,
|
||||
Prefs: ipn.Prefs{
|
||||
AdvertiseServices: services,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// parseServiceNames takes a comma-separated list of service names
|
||||
// (eg. "svc:hello,svc:webserver,svc:catphotos"), splits them into
|
||||
// a list and validates each service name. If valid, it returns
|
||||
// the service names in a slice of strings.
|
||||
func parseServiceNames(servicesArg string) ([]string, error) {
|
||||
var services []string
|
||||
if servicesArg != "" {
|
||||
services = strings.Split(servicesArg, ",")
|
||||
for _, svc := range services {
|
||||
err := tailcfg.CheckServiceName(svc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service %q: %s", svc, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
@@ -177,7 +177,7 @@ For help on subcommands, add --help after: "tailscale status --help".
|
||||
This CLI is still under active development. Commands and flags will
|
||||
change in the future.
|
||||
`),
|
||||
Subcommands: []*ffcli.Command{
|
||||
Subcommands: append([]*ffcli.Command{
|
||||
upCmd,
|
||||
downCmd,
|
||||
setCmd,
|
||||
@@ -185,10 +185,12 @@ change in the future.
|
||||
logoutCmd,
|
||||
switchCmd,
|
||||
configureCmd,
|
||||
syspolicyCmd,
|
||||
netcheckCmd,
|
||||
ipCmd,
|
||||
dnsCmd,
|
||||
statusCmd,
|
||||
metricsCmd,
|
||||
pingCmd,
|
||||
ncCmd,
|
||||
sshCmd,
|
||||
@@ -207,7 +209,7 @@ change in the future.
|
||||
debugCmd,
|
||||
driveCmd,
|
||||
idTokenCmd,
|
||||
},
|
||||
}, maybeAdvertiseCmd()...),
|
||||
FlagSet: rootfs,
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
|
||||
@@ -946,6 +946,10 @@ func TestPrefFlagMapping(t *testing.T) {
|
||||
// Handled by the tailscale share subcommand, we don't want a CLI
|
||||
// flag for this.
|
||||
continue
|
||||
case "AdvertiseServices":
|
||||
// Handled by the tailscale advertise subcommand, we don't want a
|
||||
// CLI flag for this.
|
||||
continue
|
||||
case "InternalExitNodePrior":
|
||||
// Used internally by LocalBackend as part of exit node usage toggling.
|
||||
// No CLI flag for this.
|
||||
|
||||
88
cmd/tailscale/cli/metrics.go
Normal file
88
cmd/tailscale/cli/metrics.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/atomicfile"
|
||||
)
|
||||
|
||||
var metricsCmd = &ffcli.Command{
|
||||
Name: "metrics",
|
||||
ShortHelp: "Show Tailscale metrics",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
|
||||
The 'tailscale metrics' command shows Tailscale user-facing metrics (as opposed
|
||||
to internal metrics printed by 'tailscale debug metrics').
|
||||
|
||||
For more information about Tailscale metrics, refer to
|
||||
https://tailscale.com/s/client-metrics
|
||||
|
||||
`),
|
||||
ShortUsage: "tailscale metrics <subcommand> [flags]",
|
||||
UsageFunc: usageFuncNoDefaultValues,
|
||||
Exec: runMetricsNoSubcommand,
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "print",
|
||||
ShortUsage: "tailscale metrics print",
|
||||
Exec: runMetricsPrint,
|
||||
ShortHelp: "Prints current metric values in the Prometheus text exposition format",
|
||||
},
|
||||
{
|
||||
Name: "write",
|
||||
ShortUsage: "tailscale metrics write <path>",
|
||||
Exec: runMetricsWrite,
|
||||
ShortHelp: "Writes metric values to a file",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
|
||||
The 'tailscale metrics write' command writes metric values to a text file provided as its
|
||||
only argument. It's meant to be used alongside Prometheus node exporter, allowing Tailscale
|
||||
metrics to be consumed and exported by the textfile collector.
|
||||
|
||||
As an example, to export Tailscale metrics on an Ubuntu system running node exporter, you
|
||||
can regularly run 'tailscale metrics write /var/lib/prometheus/node-exporter/tailscaled.prom'
|
||||
using cron or a systemd timer.
|
||||
|
||||
`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// runMetricsNoSubcommand prints metric values if no subcommand is specified.
|
||||
func runMetricsNoSubcommand(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("tailscale metrics: unknown subcommand: %s", args[0])
|
||||
}
|
||||
|
||||
return runMetricsPrint(ctx, args)
|
||||
}
|
||||
|
||||
// runMetricsPrint prints metric values to stdout.
|
||||
func runMetricsPrint(ctx context.Context, args []string) error {
|
||||
out, err := localClient.UserMetrics(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Stdout.Write(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMetricsWrite writes metric values to a file.
|
||||
func runMetricsWrite(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: tailscale metrics write <path>")
|
||||
}
|
||||
path := args[0]
|
||||
out, err := localClient.UserMetrics(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicfile.WriteFile(path, out, 0644)
|
||||
}
|
||||
@@ -136,6 +136,7 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
|
||||
}
|
||||
|
||||
printf("\nReport:\n")
|
||||
printf("\t* Time: %v\n", report.Now.Format(time.RFC3339Nano))
|
||||
printf("\t* UDP: %v\n", report.UDP)
|
||||
if report.GlobalV4.IsValid() {
|
||||
printf("\t* IPv4: yes, %s\n", report.GlobalV4)
|
||||
|
||||
110
cmd/tailscale/cli/syspolicy.go
Normal file
110
cmd/tailscale/cli/syspolicy.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/util/syspolicy/setting"
|
||||
)
|
||||
|
||||
var syspolicyArgs struct {
|
||||
json bool // JSON output mode
|
||||
}
|
||||
|
||||
var syspolicyCmd = &ffcli.Command{
|
||||
Name: "syspolicy",
|
||||
ShortHelp: "Diagnose the MDM and system policy configuration",
|
||||
LongHelp: "The 'tailscale syspolicy' command provides tools for diagnosing the MDM and system policy configuration.",
|
||||
ShortUsage: "tailscale syspolicy <subcommand>",
|
||||
UsageFunc: usageFuncNoDefaultValues,
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
ShortUsage: "tailscale syspolicy list",
|
||||
Exec: runSysPolicyList,
|
||||
ShortHelp: "Prints effective policy settings",
|
||||
LongHelp: "The 'tailscale syspolicy list' subcommand displays the effective policy settings and their sources (e.g., MDM or environment variables).",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("syspolicy list")
|
||||
fs.BoolVar(&syspolicyArgs.json, "json", false, "output in JSON format")
|
||||
return fs
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "reload",
|
||||
ShortUsage: "tailscale syspolicy reload",
|
||||
Exec: runSysPolicyReload,
|
||||
ShortHelp: "Forces a reload of policy settings, even if no changes are detected, and prints the result",
|
||||
LongHelp: "The 'tailscale syspolicy reload' subcommand forces a reload of policy settings, even if no changes are detected, and prints the result.",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("syspolicy reload")
|
||||
fs.BoolVar(&syspolicyArgs.json, "json", false, "output in JSON format")
|
||||
return fs
|
||||
})(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runSysPolicyList(ctx context.Context, args []string) error {
|
||||
policy, err := localClient.GetEffectivePolicy(ctx, setting.DefaultScope())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printPolicySettings(policy)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func runSysPolicyReload(ctx context.Context, args []string) error {
|
||||
policy, err := localClient.ReloadEffectivePolicy(ctx, setting.DefaultScope())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printPolicySettings(policy)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPolicySettings(policy *setting.Snapshot) {
|
||||
if syspolicyArgs.json {
|
||||
json, err := json.MarshalIndent(policy, "", "\t")
|
||||
if err != nil {
|
||||
errf("syspolicy marshalling error: %v", err)
|
||||
} else {
|
||||
outln(string(json))
|
||||
}
|
||||
return
|
||||
}
|
||||
if policy.Len() == 0 {
|
||||
outln("No policy settings")
|
||||
return
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "Name\tOrigin\tValue\tError")
|
||||
fmt.Fprintln(w, "----\t------\t-----\t-----")
|
||||
for _, k := range slices.Sorted(policy.Keys()) {
|
||||
setting, _ := policy.GetSetting(k)
|
||||
var origin string
|
||||
if o := setting.Origin(); o != nil {
|
||||
origin = o.String()
|
||||
}
|
||||
if err := setting.Error(); err != nil {
|
||||
fmt.Fprintf(w, "%s\t%s\t\t{%s}\n", k, origin, err)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t\n", k, origin, setting.Value())
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
fmt.Println()
|
||||
return
|
||||
}
|
||||
@@ -164,6 +164,9 @@ func defaultNetfilterMode() string {
|
||||
return "on"
|
||||
}
|
||||
|
||||
// upArgsT is the type of upArgs, the argument struct for `tailscale up`.
|
||||
// As of 2024-10-08, upArgsT is frozen and no new arguments should be
|
||||
// added to it. Add new arguments to setArgsT instead.
|
||||
type upArgsT struct {
|
||||
qr bool
|
||||
reset bool
|
||||
@@ -1152,6 +1155,7 @@ func resolveAuthKey(ctx context.Context, v, tags string) (string, error) {
|
||||
}
|
||||
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.UserAgent = "tailscale-cli"
|
||||
tsClient.HTTPClient = credentials.Client(ctx)
|
||||
tsClient.BaseURL = baseURL
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/disco from tailscale.com/derp
|
||||
tailscale.com/drive from tailscale.com/client/tailscale+
|
||||
tailscale.com/envknob from tailscale.com/client/tailscale+
|
||||
tailscale.com/envknob/featureknob from tailscale.com/client/web
|
||||
tailscale.com/health from tailscale.com/net/tlsdial+
|
||||
tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||
@@ -120,6 +121,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/net/stun from tailscale.com/net/netcheck
|
||||
L tailscale.com/net/tcpinfo from tailscale.com/derp
|
||||
tailscale.com/net/tlsdial from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/tsaddr from tailscale.com/client/web+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+
|
||||
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
|
||||
@@ -153,7 +155,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/util/clientmetric from tailscale.com/net/netcheck+
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
|
||||
tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy+
|
||||
tailscale.com/util/ctxkey from tailscale.com/types/logger
|
||||
tailscale.com/util/ctxkey from tailscale.com/types/logger+
|
||||
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
|
||||
@@ -172,14 +174,18 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
|
||||
tailscale.com/util/syspolicy from tailscale.com/ipn
|
||||
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
|
||||
tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
|
||||
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/usermetric from tailscale.com/health
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate
|
||||
W 💣 tailscale.com/util/winutil/gp from tailscale.com/util/syspolicy/source
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/client/web+
|
||||
tailscale.com/version/distro from tailscale.com/client/web+
|
||||
|
||||
@@ -221,7 +221,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/ip from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/multicast from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/net/tstun+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+
|
||||
💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
|
||||
@@ -263,6 +263,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/drive/driveimpl/dirfs from tailscale.com/drive/driveimpl+
|
||||
tailscale.com/drive/driveimpl/shared from tailscale.com/drive/driveimpl+
|
||||
tailscale.com/envknob from tailscale.com/client/tailscale+
|
||||
tailscale.com/envknob/featureknob from tailscale.com/client/web+
|
||||
tailscale.com/health from tailscale.com/control/controlclient+
|
||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||
@@ -321,6 +322,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
||||
L tailscale.com/net/tcpinfo from tailscale.com/derp
|
||||
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/tsaddr from tailscale.com/client/web+
|
||||
tailscale.com/net/tsdial from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+
|
||||
@@ -399,8 +401,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
|
||||
tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
|
||||
tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
|
||||
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/systemd from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
|
||||
@@ -410,7 +415,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns
|
||||
W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns+
|
||||
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
|
||||
tailscale.com/util/zstdframe from tailscale.com/control/controlclient+
|
||||
|
||||
@@ -788,7 +788,6 @@ func runDebugServer(mux *http.ServeMux, addr string) {
|
||||
}
|
||||
|
||||
func newNetstack(logf logger.Logf, sys *tsd.System) (*netstack.Impl, error) {
|
||||
tfs, _ := sys.DriveForLocal.GetOK()
|
||||
ret, err := netstack.Create(logf,
|
||||
sys.Tun.Get(),
|
||||
sys.Engine.Get(),
|
||||
@@ -796,7 +795,6 @@ func newNetstack(logf logger.Logf, sys *tsd.System) (*netstack.Impl, error) {
|
||||
sys.Dialer.Get(),
|
||||
sys.DNSManager.Get(),
|
||||
sys.ProxyMapper(),
|
||||
tfs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -42,6 +42,7 @@ type testAttempt struct {
|
||||
testName string // "TestFoo"
|
||||
outcome string // "pass", "fail", "skip"
|
||||
logs bytes.Buffer
|
||||
start, end time.Time
|
||||
isMarkedFlaky bool // set if the test is marked as flaky
|
||||
issueURL string // set if the test is marked as flaky
|
||||
|
||||
@@ -132,11 +133,17 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, te
|
||||
}
|
||||
pkg := goOutput.Package
|
||||
pkgTests := resultMap[pkg]
|
||||
if pkgTests == nil {
|
||||
pkgTests = make(map[string]*testAttempt)
|
||||
resultMap[pkg] = pkgTests
|
||||
}
|
||||
if goOutput.Test == "" {
|
||||
switch goOutput.Action {
|
||||
case "start":
|
||||
pkgTests[""] = &testAttempt{start: goOutput.Time}
|
||||
case "fail", "pass", "skip":
|
||||
for _, test := range pkgTests {
|
||||
if test.outcome == "" {
|
||||
if test.testName != "" && test.outcome == "" {
|
||||
test.outcome = "fail"
|
||||
ch <- test
|
||||
}
|
||||
@@ -144,15 +151,13 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, te
|
||||
ch <- &testAttempt{
|
||||
pkg: goOutput.Package,
|
||||
outcome: goOutput.Action,
|
||||
start: pkgTests[""].start,
|
||||
end: goOutput.Time,
|
||||
pkgFinished: true,
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if pkgTests == nil {
|
||||
pkgTests = make(map[string]*testAttempt)
|
||||
resultMap[pkg] = pkgTests
|
||||
}
|
||||
testName := goOutput.Test
|
||||
if test, _, isSubtest := strings.Cut(goOutput.Test, "/"); isSubtest {
|
||||
testName = test
|
||||
@@ -168,8 +173,10 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, te
|
||||
pkgTests[testName] = &testAttempt{
|
||||
pkg: pkg,
|
||||
testName: testName,
|
||||
start: goOutput.Time,
|
||||
}
|
||||
case "skip", "pass", "fail":
|
||||
pkgTests[testName].end = goOutput.Time
|
||||
pkgTests[testName].outcome = goOutput.Action
|
||||
ch <- pkgTests[testName]
|
||||
case "output":
|
||||
@@ -213,7 +220,7 @@ func main() {
|
||||
firstRun.tests = append(firstRun.tests, &packageTests{Pattern: pkg})
|
||||
}
|
||||
toRun := []*nextRun{firstRun}
|
||||
printPkgOutcome := func(pkg, outcome string, attempt int) {
|
||||
printPkgOutcome := func(pkg, outcome string, attempt int, runtime time.Duration) {
|
||||
if outcome == "skip" {
|
||||
fmt.Printf("?\t%s [skipped/no tests] \n", pkg)
|
||||
return
|
||||
@@ -225,10 +232,10 @@ func main() {
|
||||
outcome = "FAIL"
|
||||
}
|
||||
if attempt > 1 {
|
||||
fmt.Printf("%s\t%s [attempt=%d]\n", outcome, pkg, attempt)
|
||||
fmt.Printf("%s\t%s\t%.3fs\t[attempt=%d]\n", outcome, pkg, runtime.Seconds(), attempt)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s\t%s\n", outcome, pkg)
|
||||
fmt.Printf("%s\t%s\t%.3fs\n", outcome, pkg, runtime.Seconds())
|
||||
}
|
||||
|
||||
// Check for -coverprofile argument and filter it out
|
||||
@@ -307,7 +314,7 @@ func main() {
|
||||
// when a package times out.
|
||||
failed = true
|
||||
}
|
||||
printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt)
|
||||
printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt, tr.end.Sub(tr.start))
|
||||
continue
|
||||
}
|
||||
if testingVerbose || tr.outcome == "fail" {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
@@ -76,7 +77,10 @@ func TestFlakeRun(t *testing.T) {
|
||||
t.Fatalf("go run . %s: %s with output:\n%s", testfile, err, out)
|
||||
}
|
||||
|
||||
want := []byte("ok\t" + testfile + " [attempt=2]")
|
||||
// Replace the unpredictable timestamp with "0.00s".
|
||||
out = regexp.MustCompile(`\t\d+\.\d\d\ds\t`).ReplaceAll(out, []byte("\t0.00s\t"))
|
||||
|
||||
want := []byte("ok\t" + testfile + "\t0.00s\t[attempt=2]")
|
||||
if !bytes.Contains(out, want) {
|
||||
t.Fatalf("wanted output containing %q but got:\n%s", want, out)
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ func runEsbuildServe(buildOptions esbuild.BuildOptions) {
|
||||
log.Fatalf("Cannot start esbuild server: %v", err)
|
||||
}
|
||||
log.Printf("Listening on http://%s:%d\n", result.Host, result.Port)
|
||||
select {}
|
||||
}
|
||||
|
||||
func runEsbuild(buildOptions esbuild.BuildOptions) esbuild.BuildResult {
|
||||
|
||||
@@ -108,13 +108,14 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
SetSubsystem: sys.Set,
|
||||
ControlKnobs: sys.ControlKnobs(),
|
||||
HealthTracker: sys.HealthTracker(),
|
||||
Metrics: sys.UserMetricsRegistry(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
sys.Set(eng)
|
||||
|
||||
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper(), nil)
|
||||
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
||||
if err != nil {
|
||||
log.Fatalf("netstack.Create: %v", err)
|
||||
}
|
||||
@@ -128,6 +129,9 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
||||
return ns.DialContextTCP(ctx, dst)
|
||||
}
|
||||
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
||||
return ns.DialContextUDP(ctx, dst)
|
||||
}
|
||||
sys.NetstackRouter.Set(true)
|
||||
sys.Tun.Get().Start()
|
||||
|
||||
|
||||
@@ -258,6 +258,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
writeTemplate("unsupportedField")
|
||||
continue
|
||||
}
|
||||
it.Import("tailscale.com/types/views")
|
||||
args.MapKeyType = it.QualifiedName(key)
|
||||
mElem := m.Elem()
|
||||
var template string
|
||||
|
||||
78
cmd/viewer/viewer_test.go
Normal file
78
cmd/viewer/viewer_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/util/codegen"
|
||||
)
|
||||
|
||||
func TestViewerImports(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
typeNames []string
|
||||
wantImports []string
|
||||
}{
|
||||
{
|
||||
name: "Map",
|
||||
content: `type Test struct { Map map[string]int }`,
|
||||
typeNames: []string{"Test"},
|
||||
wantImports: []string{"tailscale.com/types/views"},
|
||||
},
|
||||
{
|
||||
name: "Slice",
|
||||
content: `type Test struct { Slice []int }`,
|
||||
typeNames: []string{"Test"},
|
||||
wantImports: []string{"tailscale.com/types/views"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "test.go", "package test\n\n"+tt.content, 0)
|
||||
if err != nil {
|
||||
fmt.Println("Error parsing:", err)
|
||||
return
|
||||
}
|
||||
|
||||
info := &types.Info{
|
||||
Types: make(map[ast.Expr]types.TypeAndValue),
|
||||
}
|
||||
|
||||
conf := types.Config{}
|
||||
pkg, err := conf.Check("", fset, []*ast.File{f}, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
tracker := codegen.NewImportTracker(pkg)
|
||||
for i := range tt.typeNames {
|
||||
typeName, ok := pkg.Scope().Lookup(tt.typeNames[i]).(*types.TypeName)
|
||||
if !ok {
|
||||
t.Fatalf("type %q does not exist", tt.typeNames[i])
|
||||
}
|
||||
namedType, ok := typeName.Type().(*types.Named)
|
||||
if !ok {
|
||||
t.Fatalf("%q is not a named type", tt.typeNames[i])
|
||||
}
|
||||
genView(&output, tracker, namedType, pkg)
|
||||
}
|
||||
|
||||
for _, pkgName := range tt.wantImports {
|
||||
if !tracker.Has(pkgName) {
|
||||
t.Errorf("missing import %q", pkgName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,7 @@ const (
|
||||
PeerPresentIsRegular = 1 << 0
|
||||
PeerPresentIsMeshPeer = 1 << 1
|
||||
PeerPresentIsProber = 1 << 2
|
||||
PeerPresentNotIdeal = 1 << 3 // client said derp server is not its Region.Nodes[0] ideal node
|
||||
)
|
||||
|
||||
var bin = binary.BigEndian
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -46,6 +47,7 @@ import (
|
||||
"tailscale.com/tstime/rate"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/ctxkey"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/util/slicesx"
|
||||
@@ -56,6 +58,16 @@ import (
|
||||
// verbosely log whenever DERP drops a packet.
|
||||
var verboseDropKeys = map[key.NodePublic]bool{}
|
||||
|
||||
// IdealNodeHeader is the HTTP request header sent on DERP HTTP client requests
|
||||
// to indicate that they're connecting to their ideal (Region.Nodes[0]) node.
|
||||
// The HTTP header value is the name of the node they wish they were connected
|
||||
// to. This is an optional header.
|
||||
const IdealNodeHeader = "Ideal-Node"
|
||||
|
||||
// IdealNodeContextKey is the context key used to pass the IdealNodeHeader value
|
||||
// from the HTTP handler to the DERP server's Accept method.
|
||||
var IdealNodeContextKey = ctxkey.New[string]("ideal-node", "")
|
||||
|
||||
func init() {
|
||||
keys := envknob.String("TS_DEBUG_VERBOSE_DROPS")
|
||||
if keys == "" {
|
||||
@@ -74,6 +86,7 @@ func init() {
|
||||
const (
|
||||
perClientSendQueueDepth = 32 // packets buffered for sending
|
||||
writeTimeout = 2 * time.Second
|
||||
privilegedWriteTimeout = 30 * time.Second // for clients with the mesh key
|
||||
)
|
||||
|
||||
// dupPolicy is a temporary (2021-08-30) mechanism to change the policy
|
||||
@@ -131,6 +144,7 @@ type Server struct {
|
||||
sentPong expvar.Int // number of pong frames enqueued to client
|
||||
accepts expvar.Int
|
||||
curClients expvar.Int
|
||||
curClientsNotIdeal expvar.Int
|
||||
curHomeClients expvar.Int // ones with preferred
|
||||
dupClientKeys expvar.Int // current number of public keys we have 2+ connections for
|
||||
dupClientConns expvar.Int // current number of connections sharing a public key
|
||||
@@ -141,6 +155,7 @@ type Server struct {
|
||||
multiForwarderCreated expvar.Int
|
||||
multiForwarderDeleted expvar.Int
|
||||
removePktForwardOther expvar.Int
|
||||
sclientWriteTimeouts expvar.Int
|
||||
avgQueueDuration *uint64 // In milliseconds; accessed atomically
|
||||
tcpRtt metrics.LabelMap // histogram
|
||||
meshUpdateBatchSize *metrics.Histogram
|
||||
@@ -600,6 +615,9 @@ func (s *Server) registerClient(c *sclient) {
|
||||
}
|
||||
s.keyOfAddr[c.remoteIPPort] = c.key
|
||||
s.curClients.Add(1)
|
||||
if c.isNotIdealConn {
|
||||
s.curClientsNotIdeal.Add(1)
|
||||
}
|
||||
s.broadcastPeerStateChangeLocked(c.key, c.remoteIPPort, c.presentFlags(), true)
|
||||
}
|
||||
|
||||
@@ -690,6 +708,9 @@ func (s *Server) unregisterClient(c *sclient) {
|
||||
if c.preferred {
|
||||
s.curHomeClients.Add(-1)
|
||||
}
|
||||
if c.isNotIdealConn {
|
||||
s.curClientsNotIdeal.Add(-1)
|
||||
}
|
||||
}
|
||||
|
||||
// addPeerGoneFromRegionWatcher adds a function to be called when peer is gone
|
||||
@@ -806,8 +827,8 @@ func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, rem
|
||||
return fmt.Errorf("receive client key: %v", err)
|
||||
}
|
||||
|
||||
clientAP, _ := netip.ParseAddrPort(remoteAddr)
|
||||
if err := s.verifyClient(ctx, clientKey, clientInfo, clientAP.Addr()); err != nil {
|
||||
remoteIPPort, _ := netip.ParseAddrPort(remoteAddr)
|
||||
if err := s.verifyClient(ctx, clientKey, clientInfo, remoteIPPort.Addr()); err != nil {
|
||||
return fmt.Errorf("client %v rejected: %v", clientKey, err)
|
||||
}
|
||||
|
||||
@@ -817,8 +838,6 @@ func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, rem
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
remoteIPPort, _ := netip.ParseAddrPort(remoteAddr)
|
||||
|
||||
c := &sclient{
|
||||
connNum: connNum,
|
||||
s: s,
|
||||
@@ -835,6 +854,7 @@ func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, rem
|
||||
sendPongCh: make(chan [8]byte, 1),
|
||||
peerGone: make(chan peerGoneMsg),
|
||||
canMesh: s.isMeshPeer(clientInfo),
|
||||
isNotIdealConn: IdealNodeContextKey.Value(ctx) != "",
|
||||
peerGoneLim: rate.NewLimiter(rate.Every(time.Second), 3),
|
||||
}
|
||||
|
||||
@@ -881,6 +901,9 @@ func (c *sclient) run(ctx context.Context) error {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
c.debugLogf("sender canceled by reader exiting")
|
||||
} else {
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
c.s.sclientWriteTimeouts.Add(1)
|
||||
}
|
||||
c.logf("sender failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1505,6 +1528,7 @@ type sclient struct {
|
||||
peerGone chan peerGoneMsg // write request that a peer is not at this server (not used by mesh peers)
|
||||
meshUpdate chan struct{} // write request to write peerStateChange
|
||||
canMesh bool // clientInfo had correct mesh token for inter-region routing
|
||||
isNotIdealConn bool // client indicated it is not its ideal node in the region
|
||||
isDup atomic.Bool // whether more than 1 sclient for key is connected
|
||||
isDisabled atomic.Bool // whether sends to this peer are disabled due to active/active dups
|
||||
debug bool // turn on for verbose logging
|
||||
@@ -1540,6 +1564,9 @@ func (c *sclient) presentFlags() PeerPresentFlags {
|
||||
if c.canMesh {
|
||||
f |= PeerPresentIsMeshPeer
|
||||
}
|
||||
if c.isNotIdealConn {
|
||||
f |= PeerPresentNotIdeal
|
||||
}
|
||||
if f == 0 {
|
||||
return PeerPresentIsRegular
|
||||
}
|
||||
@@ -1721,7 +1748,19 @@ func (c *sclient) sendLoop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (c *sclient) setWriteDeadline() {
|
||||
c.nc.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
d := writeTimeout
|
||||
if c.canMesh {
|
||||
// Trusted peers get more tolerance.
|
||||
//
|
||||
// The "canMesh" is a bit of a misnomer; mesh peers typically run over a
|
||||
// different interface for a per-region private VPC and are not
|
||||
// throttled. But monitoring software elsewhere over the internet also
|
||||
// use the private mesh key to subscribe to connect/disconnect events
|
||||
// and might hit throttling and need more time to get the initial dump
|
||||
// of connected peers.
|
||||
d = privilegedWriteTimeout
|
||||
}
|
||||
c.nc.SetWriteDeadline(time.Now().Add(d))
|
||||
}
|
||||
|
||||
// sendKeepAlive sends a keep-alive frame, without flushing.
|
||||
@@ -2033,6 +2072,7 @@ func (s *Server) ExpVar() expvar.Var {
|
||||
m.Set("gauge_current_file_descriptors", expvar.Func(func() any { return metrics.CurrentFDs() }))
|
||||
m.Set("gauge_current_connections", &s.curClients)
|
||||
m.Set("gauge_current_home_connections", &s.curHomeClients)
|
||||
m.Set("gauge_current_notideal_connections", &s.curClientsNotIdeal)
|
||||
m.Set("gauge_clients_total", expvar.Func(func() any { return len(s.clientsMesh) }))
|
||||
m.Set("gauge_clients_local", expvar.Func(func() any { return len(s.clients) }))
|
||||
m.Set("gauge_clients_remote", expvar.Func(func() any { return len(s.clientsMesh) - len(s.clients) }))
|
||||
@@ -2060,6 +2100,7 @@ func (s *Server) ExpVar() expvar.Var {
|
||||
m.Set("multiforwarder_created", &s.multiForwarderCreated)
|
||||
m.Set("multiforwarder_deleted", &s.multiForwarderDeleted)
|
||||
m.Set("packet_forwarder_delete_other_value", &s.removePktForwardOther)
|
||||
m.Set("sclient_write_timeouts", &s.sclientWriteTimeouts)
|
||||
m.Set("average_queue_duration_ms", expvar.Func(func() any {
|
||||
return math.Float64frombits(atomic.LoadUint64(s.avgQueueDuration))
|
||||
}))
|
||||
|
||||
@@ -498,7 +498,7 @@ func (c *Client) connect(ctx context.Context, caller string) (client *derp.Clien
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
if !idealNodeInRegion && reg != nil {
|
||||
// This is purely informative for now (2024-07-06) for stats:
|
||||
req.Header.Set("Ideal-Node", reg.Nodes[0].Name)
|
||||
req.Header.Set(derp.IdealNodeHeader, reg.Nodes[0].Name)
|
||||
// TODO(bradfitz,raggi): start a time.AfterFunc for 30m-1h or so to
|
||||
// dialNode(reg.Nodes[0]) and see if we can even TCP connect to it. If
|
||||
// so, TLS handshake it as well (which is mixed up in this massive
|
||||
|
||||
@@ -21,6 +21,8 @@ const fastStartHeader = "Derp-Fast-Start"
|
||||
// Handler returns an http.Handler to be mounted at /derp, serving s.
|
||||
func Handler(s *derp.Server) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// These are installed both here and in cmd/derper. The check here
|
||||
// catches both cmd/derper run with DERP disabled (STUN only mode) as
|
||||
// well as DERP being run in tests with derphttp.Handler directly,
|
||||
@@ -66,7 +68,11 @@ func Handler(s *derp.Server) http.Handler {
|
||||
pubKey.UntypedHexString())
|
||||
}
|
||||
|
||||
s.Accept(r.Context(), netConn, conn, netConn.RemoteAddr().String())
|
||||
if v := r.Header.Get(derp.IdealNodeHeader); v != "" {
|
||||
ctx = derp.IdealNodeContextKey.WithValue(ctx, v)
|
||||
}
|
||||
|
||||
s.Accept(ctx, netConn, conn, netConn.RemoteAddr().String())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
68
envknob/featureknob/featureknob.go
Normal file
68
envknob/featureknob/featureknob.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package featureknob provides a facility to control whether features
|
||||
// can run based on either an envknob or running OS / distro.
|
||||
package featureknob
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
// CanRunTailscaleSSH reports whether serving a Tailscale SSH server is
|
||||
// supported for the current os/distro.
|
||||
func CanRunTailscaleSSH() error {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if distro.Get() == distro.Synology && !envknob.UseWIPCode() {
|
||||
return errors.New("The Tailscale SSH server does not run on Synology.")
|
||||
}
|
||||
if distro.Get() == distro.QNAP && !envknob.UseWIPCode() {
|
||||
return errors.New("The Tailscale SSH server does not run on QNAP.")
|
||||
}
|
||||
|
||||
// Setting SSH on Home Assistant causes trouble on startup
|
||||
// (since the flag is not being passed to `tailscale up`).
|
||||
// Although Tailscale SSH does work here,
|
||||
// it's not terribly useful since it's running in a separate container.
|
||||
if hostinfo.GetEnvType() == hostinfo.HomeAssistantAddOn {
|
||||
return errors.New("The Tailscale SSH server does not run on HomeAssistant.")
|
||||
}
|
||||
// otherwise okay
|
||||
case "darwin":
|
||||
// okay only in tailscaled mode for now.
|
||||
if version.IsSandboxedMacOS() {
|
||||
return errors.New("The Tailscale SSH server does not run in sandboxed Tailscale GUI builds.")
|
||||
}
|
||||
case "freebsd", "openbsd":
|
||||
default:
|
||||
return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS)
|
||||
}
|
||||
if !envknob.CanSSHD() {
|
||||
return errors.New("The Tailscale SSH server has been administratively disabled.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanUseExitNode reports whether using an exit node is supported for the
|
||||
// current os/distro.
|
||||
func CanUseExitNode() error {
|
||||
switch dist := distro.Get(); dist {
|
||||
case distro.Synology, // see https://github.com/tailscale/tailscale/issues/1995
|
||||
distro.QNAP,
|
||||
distro.Unraid:
|
||||
return errors.New("Tailscale exit nodes cannot be used on " + string(dist))
|
||||
}
|
||||
|
||||
if hostinfo.GetEnvType() == hostinfo.HomeAssistantAddOn {
|
||||
return errors.New("Tailscale exit nodes cannot be used on HomeAssistant.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package envknob
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
// CanRunTailscaleSSH reports whether serving a Tailscale SSH server is
|
||||
// supported for the current os/distro.
|
||||
func CanRunTailscaleSSH() error {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if distro.Get() == distro.Synology && !UseWIPCode() {
|
||||
return errors.New("The Tailscale SSH server does not run on Synology.")
|
||||
}
|
||||
if distro.Get() == distro.QNAP && !UseWIPCode() {
|
||||
return errors.New("The Tailscale SSH server does not run on QNAP.")
|
||||
}
|
||||
// otherwise okay
|
||||
case "darwin":
|
||||
// okay only in tailscaled mode for now.
|
||||
if version.IsSandboxedMacOS() {
|
||||
return errors.New("The Tailscale SSH server does not run in sandboxed Tailscale GUI builds.")
|
||||
}
|
||||
case "freebsd", "openbsd":
|
||||
default:
|
||||
return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS)
|
||||
}
|
||||
if !CanSSHD() {
|
||||
return errors.New("The Tailscale SSH server has been administratively disabled.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -128,9 +128,6 @@ const (
|
||||
// SysDNS is the name of the net/dns subsystem.
|
||||
SysDNS = Subsystem("dns")
|
||||
|
||||
// SysDNSOS is the name of the net/dns OSConfigurator subsystem.
|
||||
SysDNSOS = Subsystem("dns-os")
|
||||
|
||||
// SysDNSManager is the name of the net/dns manager subsystem.
|
||||
SysDNSManager = Subsystem("dns-manager")
|
||||
|
||||
@@ -141,7 +138,7 @@ const (
|
||||
var subsystemsWarnables = map[Subsystem]*Warnable{}
|
||||
|
||||
func init() {
|
||||
for _, s := range []Subsystem{SysRouter, SysDNS, SysDNSOS, SysDNSManager, SysTKA} {
|
||||
for _, s := range []Subsystem{SysRouter, SysDNS, SysDNSManager, SysTKA} {
|
||||
w := Register(&Warnable{
|
||||
Code: WarnableCode(s),
|
||||
Severity: SeverityMedium,
|
||||
@@ -510,22 +507,12 @@ func (t *Tracker) SetDNSHealth(err error) { t.setErr(SysDNS, err) }
|
||||
// Deprecated: Warnables should be preferred over Subsystem errors.
|
||||
func (t *Tracker) DNSHealth() error { return t.get(SysDNS) }
|
||||
|
||||
// SetDNSOSHealth sets the state of the net/dns.OSConfigurator
|
||||
//
|
||||
// Deprecated: Warnables should be preferred over Subsystem errors.
|
||||
func (t *Tracker) SetDNSOSHealth(err error) { t.setErr(SysDNSOS, err) }
|
||||
|
||||
// SetDNSManagerHealth sets the state of the Linux net/dns manager's
|
||||
// discovery of the /etc/resolv.conf situation.
|
||||
//
|
||||
// Deprecated: Warnables should be preferred over Subsystem errors.
|
||||
func (t *Tracker) SetDNSManagerHealth(err error) { t.setErr(SysDNSManager, err) }
|
||||
|
||||
// DNSOSHealth returns the net/dns.OSConfigurator error state.
|
||||
//
|
||||
// Deprecated: Warnables should be preferred over Subsystem errors.
|
||||
func (t *Tracker) DNSOSHealth() error { return t.get(SysDNSOS) }
|
||||
|
||||
// SetTKAHealth sets the health of the tailnet key authority.
|
||||
//
|
||||
// Deprecated: Warnables should be preferred over Subsystem errors.
|
||||
@@ -1051,11 +1038,15 @@ func (t *Tracker) updateBuiltinWarnablesLocked() {
|
||||
ArgDuration: d.Round(time.Second).String(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
} else if homeDERP != 0 {
|
||||
t.setUnhealthyLocked(noDERPConnectionWarnable, Args{
|
||||
ArgDERPRegionID: fmt.Sprint(homeDERP),
|
||||
ArgDERPRegionName: t.derpRegionNameLocked(homeDERP),
|
||||
})
|
||||
} else {
|
||||
// No DERP home yet determined yet. There's probably some
|
||||
// other problem or things are just starting up.
|
||||
t.setHealthyLocked(noDERPConnectionWarnable)
|
||||
}
|
||||
|
||||
if !t.ipnWantRunning {
|
||||
|
||||
@@ -280,13 +280,22 @@ func getEnvType() EnvType {
|
||||
return ""
|
||||
}
|
||||
|
||||
// inContainer reports whether we're running in a container.
|
||||
// inContainer reports whether we're running in a container. Best-effort only,
|
||||
// there's no foolproof way to detect this, but the build tag should catch all
|
||||
// official builds from 1.78.0.
|
||||
func inContainer() opt.Bool {
|
||||
if runtime.GOOS != "linux" {
|
||||
return ""
|
||||
}
|
||||
var ret opt.Bool
|
||||
ret.Set(false)
|
||||
if packageType != nil && packageType() == "container" {
|
||||
// Go build tag ts_package_container was set during build.
|
||||
ret.Set(true)
|
||||
return ret
|
||||
}
|
||||
// Only set if using docker's container runtime. Not guaranteed by
|
||||
// documentation, but it's been in place for a long time.
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
ret.Set(true)
|
||||
return ret
|
||||
@@ -362,7 +371,7 @@ func inFlyDotIo() bool {
|
||||
}
|
||||
|
||||
func inReplit() bool {
|
||||
// https://docs.replit.com/programming-ide/getting-repl-metadata
|
||||
// https://docs.replit.com/replit-workspace/configuring-repl#environment-variables
|
||||
if os.Getenv("REPL_OWNER") != "" && os.Getenv("REPL_SLUG") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
16
hostinfo/hostinfo_container_linux_test.go
Normal file
16
hostinfo/hostinfo_container_linux_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux && !android && ts_package_container
|
||||
|
||||
package hostinfo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInContainer(t *testing.T) {
|
||||
if got := inContainer(); !got.EqualBool(true) {
|
||||
t.Errorf("inContainer = %v; want true due to ts_package_container build tag", got)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux && !android
|
||||
//go:build linux && !android && !ts_package_container
|
||||
|
||||
package hostinfo
|
||||
|
||||
@@ -34,3 +34,9 @@ remotes/origin/QTSFW_5.0.0`
|
||||
t.Errorf("got %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ type ConfigVAlpha struct {
|
||||
AdvertiseRoutes []netip.Prefix `json:",omitempty"`
|
||||
DisableSNAT opt.Bool `json:",omitempty"`
|
||||
|
||||
AppConnector *AppConnectorPrefs `json:",omitempty"` // advertise app connector; defaults to false (if nil or explicitly set to false)
|
||||
|
||||
NetfilterMode *string `json:",omitempty"` // "on", "off", "nodivert"
|
||||
NoStatefulFiltering opt.Bool `json:",omitempty"`
|
||||
|
||||
@@ -137,5 +139,9 @@ func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) {
|
||||
mp.AutoUpdate = *c.AutoUpdate
|
||||
mp.AutoUpdateSet = AutoUpdatePrefsMask{ApplySet: true, CheckSet: true}
|
||||
}
|
||||
if c.AppConnector != nil {
|
||||
mp.AppConnector = *c.AppConnector
|
||||
mp.AppConnectorSet = true
|
||||
}
|
||||
return mp, nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func (src *Prefs) Clone() *Prefs {
|
||||
*dst = *src
|
||||
dst.AdvertiseTags = append(src.AdvertiseTags[:0:0], src.AdvertiseTags...)
|
||||
dst.AdvertiseRoutes = append(src.AdvertiseRoutes[:0:0], src.AdvertiseRoutes...)
|
||||
dst.AdvertiseServices = append(src.AdvertiseServices[:0:0], src.AdvertiseServices...)
|
||||
if src.DriveShares != nil {
|
||||
dst.DriveShares = make([]*drive.Share, len(src.DriveShares))
|
||||
for i := range dst.DriveShares {
|
||||
@@ -61,6 +62,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
|
||||
ForceDaemon bool
|
||||
Egg bool
|
||||
AdvertiseRoutes []netip.Prefix
|
||||
AdvertiseServices []string
|
||||
NoSNAT bool
|
||||
NoStatefulFiltering opt.Bool
|
||||
NetfilterMode preftype.NetfilterMode
|
||||
|
||||
@@ -85,6 +85,9 @@ func (v PrefsView) Egg() bool { return v.ж.Eg
|
||||
func (v PrefsView) AdvertiseRoutes() views.Slice[netip.Prefix] {
|
||||
return views.SliceOf(v.ж.AdvertiseRoutes)
|
||||
}
|
||||
func (v PrefsView) AdvertiseServices() views.Slice[string] {
|
||||
return views.SliceOf(v.ж.AdvertiseServices)
|
||||
}
|
||||
func (v PrefsView) NoSNAT() bool { return v.ж.NoSNAT }
|
||||
func (v PrefsView) NoStatefulFiltering() opt.Bool { return v.ж.NoStatefulFiltering }
|
||||
func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.NetfilterMode }
|
||||
@@ -120,6 +123,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
|
||||
ForceDaemon bool
|
||||
Egg bool
|
||||
AdvertiseRoutes []netip.Prefix
|
||||
AdvertiseServices []string
|
||||
NoSNAT bool
|
||||
NoStatefulFiltering opt.Bool
|
||||
NetfilterMode preftype.NetfilterMode
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
@@ -20,6 +22,9 @@ type Actor interface {
|
||||
// Username returns the user name associated with the receiver,
|
||||
// or "" if the actor does not represent a specific user.
|
||||
Username() (string, error)
|
||||
// ClientID returns a non-zero ClientID and true if the actor represents
|
||||
// a connected LocalAPI client. Otherwise, it returns a zero value and false.
|
||||
ClientID() (_ ClientID, ok bool)
|
||||
|
||||
// IsLocalSystem reports whether the actor is the Windows' Local System account.
|
||||
//
|
||||
@@ -45,3 +50,29 @@ type ActorCloser interface {
|
||||
// Close releases resources associated with the receiver.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ClientID is an opaque, comparable value used to identify a connected LocalAPI
|
||||
// client, such as a connected Tailscale GUI or CLI. It does not necessarily
|
||||
// correspond to the same [net.Conn] or any physical session.
|
||||
//
|
||||
// Its zero value is valid, but does not represent a specific connected client.
|
||||
type ClientID struct {
|
||||
v any
|
||||
}
|
||||
|
||||
// NoClientID is the zero value of [ClientID].
|
||||
var NoClientID ClientID
|
||||
|
||||
// ClientIDFrom returns a new [ClientID] derived from the specified value.
|
||||
// ClientIDs derived from equal values are equal.
|
||||
func ClientIDFrom[T comparable](v T) ClientID {
|
||||
return ClientID{v}
|
||||
}
|
||||
|
||||
// String implements [fmt.Stringer].
|
||||
func (id ClientID) String() string {
|
||||
if id.v == nil {
|
||||
return "(none)"
|
||||
}
|
||||
return fmt.Sprint(id.v)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
func GetConnIdentity(_ logger.Logf, c net.Conn) (ci *ConnIdentity, err error) {
|
||||
ci = &ConnIdentity{conn: c, notWindows: true}
|
||||
_, ci.isUnixSock = c.(*net.UnixConn)
|
||||
ci.creds, _ = peercred.Get(c)
|
||||
if ci.creds, _ = peercred.Get(c); ci.creds != nil {
|
||||
ci.pid, _ = ci.creds.PID()
|
||||
}
|
||||
return ci, nil
|
||||
}
|
||||
|
||||
|
||||
36
ipn/ipnauth/test_actor.go
Normal file
36
ipn/ipnauth/test_actor.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
var _ Actor = (*TestActor)(nil)
|
||||
|
||||
// TestActor is an [Actor] used exclusively for testing purposes.
|
||||
type TestActor struct {
|
||||
UID ipn.WindowsUserID // OS-specific UID of the user, if the actor represents a local Windows user
|
||||
Name string // username associated with the actor, or ""
|
||||
NameErr error // error to be returned by [TestActor.Username]
|
||||
CID ClientID // non-zero if the actor represents a connected LocalAPI client
|
||||
LocalSystem bool // whether the actor represents the special Local System account on Windows
|
||||
LocalAdmin bool // whether the actor has local admin access
|
||||
|
||||
}
|
||||
|
||||
// UserID implements [Actor].
|
||||
func (a *TestActor) UserID() ipn.WindowsUserID { return a.UID }
|
||||
|
||||
// Username implements [Actor].
|
||||
func (a *TestActor) Username() (string, error) { return a.Name, a.NameErr }
|
||||
|
||||
// ClientID implements [Actor].
|
||||
func (a *TestActor) ClientID() (_ ClientID, ok bool) { return a.CID, a.CID != NoClientID }
|
||||
|
||||
// IsLocalSystem implements [Actor].
|
||||
func (a *TestActor) IsLocalSystem() bool { return a.LocalSystem }
|
||||
|
||||
// IsLocalAdmin implements [Actor].
|
||||
func (a *TestActor) IsLocalAdmin(operatorUID string) bool { return a.LocalAdmin }
|
||||
@@ -332,12 +332,10 @@ func handleC2NPostureIdentityGet(b *LocalBackend, w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
if choice.ShouldEnable(b.Prefs().PostureChecking()) {
|
||||
sns, err := posture.GetSerialNumbers(b.logf)
|
||||
res.SerialNumbers, err = posture.GetSerialNumbers(b.logf)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
b.logf("c2n: GetSerialNumbers returned error: %v", err)
|
||||
}
|
||||
res.SerialNumbers = sns
|
||||
|
||||
// TODO(tailscale/corp#21371, 2024-07-10): once this has landed in a stable release
|
||||
// and looks good in client metrics, remove this parameter and always report MAC
|
||||
|
||||
@@ -51,6 +51,7 @@ import (
|
||||
"tailscale.com/doctor/routetable"
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/envknob/featureknob"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/health/healthmsg"
|
||||
"tailscale.com/hostinfo"
|
||||
@@ -154,10 +155,12 @@ func RegisterNewSSHServer(fn newSSHServerFunc) {
|
||||
newSSHServer = fn
|
||||
}
|
||||
|
||||
// watchSession represents a WatchNotifications channel
|
||||
// watchSession represents a WatchNotifications channel,
|
||||
// an [ipnauth.Actor] that owns it (e.g., a connected GUI/CLI),
|
||||
// and sessionID as required to close targeted buses.
|
||||
type watchSession struct {
|
||||
ch chan *ipn.Notify
|
||||
owner ipnauth.Actor // or nil
|
||||
sessionID string
|
||||
cancel func() // call to signal that the session must be terminated
|
||||
}
|
||||
@@ -264,9 +267,9 @@ type LocalBackend struct {
|
||||
endpoints []tailcfg.Endpoint
|
||||
blocked bool
|
||||
keyExpired bool
|
||||
authURL string // non-empty if not Running
|
||||
authURLTime time.Time // when the authURL was received from the control server
|
||||
interact bool // indicates whether a user requested interactive login
|
||||
authURL string // non-empty if not Running
|
||||
authURLTime time.Time // when the authURL was received from the control server
|
||||
authActor ipnauth.Actor // an actor who called [LocalBackend.StartLoginInteractive] last, or nil
|
||||
egg bool
|
||||
prevIfState *netmon.State
|
||||
peerAPIServer *peerAPIServer // or nil
|
||||
@@ -396,11 +399,6 @@ type metrics struct {
|
||||
// approvedRoutes is a metric that reports the number of network routes served by the local node and approved
|
||||
// by the control server.
|
||||
approvedRoutes *usermetric.Gauge
|
||||
|
||||
// primaryRoutes is a metric that reports the number of primary network routes served by the local node.
|
||||
// A route being a primary route implies that the route is currently served by this node, and not by another
|
||||
// subnet router in a high availability configuration.
|
||||
primaryRoutes *usermetric.Gauge
|
||||
}
|
||||
|
||||
// clientGen is a func that creates a control plane client.
|
||||
@@ -451,8 +449,6 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
"tailscaled_advertised_routes", "Number of advertised network routes (e.g. by a subnet router)"),
|
||||
approvedRoutes: sys.UserMetricsRegistry().NewGauge(
|
||||
"tailscaled_approved_routes", "Number of approved network routes (e.g. by a subnet router)"),
|
||||
primaryRoutes: sys.UserMetricsRegistry().NewGauge(
|
||||
"tailscaled_primary_routes", "Number of network routes for which this node is a primary router (in high availability configuration)"),
|
||||
}
|
||||
|
||||
b := &LocalBackend{
|
||||
@@ -483,7 +479,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
mConn.SetNetInfoCallback(b.setNetInfo)
|
||||
|
||||
if sys.InitialConfig != nil {
|
||||
if err := b.setConfigLocked(sys.InitialConfig); err != nil {
|
||||
if err := b.initPrefsFromConfig(sys.InitialConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -716,8 +712,8 @@ func (b *LocalBackend) SetDirectFileRoot(dir string) {
|
||||
// It returns (false, nil) if not running in declarative mode, (true, nil) on
|
||||
// success, or (false, error) on failure.
|
||||
func (b *LocalBackend) ReloadConfig() (ok bool, err error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
unlock := b.lockAndGetUnlock()
|
||||
defer unlock()
|
||||
if b.conf == nil {
|
||||
return false, nil
|
||||
}
|
||||
@@ -725,18 +721,21 @@ func (b *LocalBackend) ReloadConfig() (ok bool, err error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := b.setConfigLocked(conf); err != nil {
|
||||
if err := b.setConfigLockedOnEntry(conf, unlock); err != nil {
|
||||
return false, fmt.Errorf("error setting config: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) setConfigLocked(conf *conffile.Config) error {
|
||||
|
||||
// TODO(irbekrm): notify the relevant components to consume any prefs
|
||||
// updates. Currently only initial configfile settings are applied
|
||||
// immediately.
|
||||
// initPrefsFromConfig initializes the backend's prefs from the provided config.
|
||||
// This should only be called once, at startup. For updates at runtime, use
|
||||
// [LocalBackend.setConfigLocked].
|
||||
func (b *LocalBackend) initPrefsFromConfig(conf *conffile.Config) error {
|
||||
// TODO(maisem,bradfitz): combine this with setConfigLocked. This is called
|
||||
// before anything is running, so there's no need to lock and we don't
|
||||
// update any subsystems. At runtime, we both need to lock and update
|
||||
// subsystems with the new prefs.
|
||||
p := b.pm.CurrentPrefs().AsStruct()
|
||||
mp, err := conf.Parsed.ToPrefs()
|
||||
if err != nil {
|
||||
@@ -746,13 +745,14 @@ func (b *LocalBackend) setConfigLocked(conf *conffile.Config) error {
|
||||
if err := b.pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setStaticEndpointsFromConfigLocked(conf)
|
||||
b.conf = conf
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
b.conf = conf
|
||||
}()
|
||||
|
||||
func (b *LocalBackend) setStaticEndpointsFromConfigLocked(conf *conffile.Config) {
|
||||
if conf.Parsed.StaticEndpoints == nil && (b.conf == nil || b.conf.Parsed.StaticEndpoints == nil) {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure that magicsock conn has the up to date static wireguard
|
||||
@@ -766,6 +766,22 @@ func (b *LocalBackend) setConfigLocked(conf *conffile.Config) error {
|
||||
ms.SetStaticEndpoints(views.SliceOf(conf.Parsed.StaticEndpoints))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setConfigLockedOnEntry uses the provided config to update the backend's prefs
|
||||
// and other state.
|
||||
func (b *LocalBackend) setConfigLockedOnEntry(conf *conffile.Config, unlock unlockOnce) error {
|
||||
defer unlock()
|
||||
p := b.pm.CurrentPrefs().AsStruct()
|
||||
mp, err := conf.Parsed.ToPrefs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing config to prefs: %w", err)
|
||||
}
|
||||
p.ApplyEdits(&mp)
|
||||
b.setStaticEndpointsFromConfigLocked(conf)
|
||||
b.setPrefsLockedOnEntry(p, unlock)
|
||||
|
||||
b.conf = conf
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2128,10 +2144,10 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
|
||||
blid := b.backendLogID.String()
|
||||
b.logf("Backend: logs: be:%v fe:%v", blid, opts.FrontendLogID)
|
||||
b.sendLocked(ipn.Notify{
|
||||
b.sendToLocked(ipn.Notify{
|
||||
BackendLogID: &blid,
|
||||
Prefs: &prefs,
|
||||
})
|
||||
}, allClients)
|
||||
|
||||
if !loggedOut && (b.hasNodeKeyLocked() || confWantRunning) {
|
||||
// If we know that we're either logged in or meant to be
|
||||
@@ -2656,10 +2672,15 @@ func applyConfigToHostinfo(hi *tailcfg.Hostinfo, c *conffile.Config) {
|
||||
// notifications. There is currently (2022-11-22) no mechanism provided to
|
||||
// detect when a message has been dropped.
|
||||
func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWatchOpt, onWatchAdded func(), fn func(roNotify *ipn.Notify) (keepGoing bool)) {
|
||||
b.WatchNotificationsAs(ctx, nil, mask, onWatchAdded, fn)
|
||||
}
|
||||
|
||||
// WatchNotificationsAs is like WatchNotifications but takes an [ipnauth.Actor]
|
||||
// as an additional parameter. If non-nil, the specified callback is invoked
|
||||
// only for notifications relevant to this actor.
|
||||
func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.Actor, mask ipn.NotifyWatchOpt, onWatchAdded func(), fn func(roNotify *ipn.Notify) (keepGoing bool)) {
|
||||
ch := make(chan *ipn.Notify, 128)
|
||||
|
||||
sessionID := rands.HexString(16)
|
||||
|
||||
origFn := fn
|
||||
if mask&ipn.NotifyNoPrivateKeys != 0 {
|
||||
fn = func(n *ipn.Notify) bool {
|
||||
@@ -2711,6 +2732,7 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
|
||||
|
||||
session := &watchSession{
|
||||
ch: ch,
|
||||
owner: actor,
|
||||
sessionID: sessionID,
|
||||
cancel: cancel,
|
||||
}
|
||||
@@ -2833,13 +2855,71 @@ func (b *LocalBackend) DebugPickNewDERP() error {
|
||||
//
|
||||
// b.mu must not be held.
|
||||
func (b *LocalBackend) send(n ipn.Notify) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.sendLocked(n)
|
||||
b.sendTo(n, allClients)
|
||||
}
|
||||
|
||||
// sendLocked is like send, but assumes b.mu is already held.
|
||||
func (b *LocalBackend) sendLocked(n ipn.Notify) {
|
||||
// notificationTarget describes a notification recipient.
|
||||
// A zero value is valid and indicate that the notification
|
||||
// should be broadcast to all active [watchSession]s.
|
||||
type notificationTarget struct {
|
||||
// userID is the OS-specific UID of the target user.
|
||||
// If empty, the notification is not user-specific and
|
||||
// will be broadcast to all connected users.
|
||||
// TODO(nickkhyl): make this field cross-platform rather
|
||||
// than Windows-specific.
|
||||
userID ipn.WindowsUserID
|
||||
// clientID identifies a client that should be the exclusive recipient
|
||||
// of the notification. A zero value indicates that notification should
|
||||
// be sent to all sessions of the specified user.
|
||||
clientID ipnauth.ClientID
|
||||
}
|
||||
|
||||
var allClients = notificationTarget{} // broadcast to all connected clients
|
||||
|
||||
// toNotificationTarget returns a [notificationTarget] that matches only actors
|
||||
// representing the same user as the specified actor. If the actor represents
|
||||
// a specific connected client, the [ipnauth.ClientID] must also match.
|
||||
// If the actor is nil, the [notificationTarget] matches all actors.
|
||||
func toNotificationTarget(actor ipnauth.Actor) notificationTarget {
|
||||
t := notificationTarget{}
|
||||
if actor != nil {
|
||||
t.userID = actor.UserID()
|
||||
t.clientID, _ = actor.ClientID()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// match reports whether the specified actor should receive notifications
|
||||
// targeting t. If the actor is nil, it should only receive notifications
|
||||
// intended for all users.
|
||||
func (t notificationTarget) match(actor ipnauth.Actor) bool {
|
||||
if t == allClients {
|
||||
return true
|
||||
}
|
||||
if actor == nil {
|
||||
return false
|
||||
}
|
||||
if t.userID != "" && t.userID != actor.UserID() {
|
||||
return false
|
||||
}
|
||||
if t.clientID != ipnauth.NoClientID {
|
||||
clientID, ok := actor.ClientID()
|
||||
if !ok || clientID != t.clientID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sendTo is like [LocalBackend.send] but allows specifying a recipient.
|
||||
func (b *LocalBackend) sendTo(n ipn.Notify, recipient notificationTarget) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.sendToLocked(n, recipient)
|
||||
}
|
||||
|
||||
// sendToLocked is like [LocalBackend.sendTo], but assumes b.mu is already held.
|
||||
func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget) {
|
||||
if n.Prefs != nil {
|
||||
n.Prefs = ptr.To(stripKeysFromPrefs(*n.Prefs))
|
||||
}
|
||||
@@ -2853,10 +2933,12 @@ func (b *LocalBackend) sendLocked(n ipn.Notify) {
|
||||
}
|
||||
|
||||
for _, sess := range b.notifyWatchers {
|
||||
select {
|
||||
case sess.ch <- &n:
|
||||
default:
|
||||
// Drop the notification if the channel is full.
|
||||
if recipient.match(sess.owner) {
|
||||
select {
|
||||
case sess.ch <- &n:
|
||||
default:
|
||||
// Drop the notification if the channel is full.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2891,15 +2973,18 @@ func (b *LocalBackend) sendFileNotify() {
|
||||
// This method is called when a new authURL is received from the control plane, meaning that either a user
|
||||
// has started a new interactive login (e.g., by running `tailscale login` or clicking Login in the GUI),
|
||||
// or the control plane was unable to authenticate this node non-interactively (e.g., due to key expiration).
|
||||
// b.interact indicates whether an interactive login is in progress.
|
||||
// A non-nil b.authActor indicates that an interactive login is in progress and was initiated by the specified actor.
|
||||
// If url is "", it is equivalent to calling [LocalBackend.resetAuthURLLocked] with b.mu held.
|
||||
func (b *LocalBackend) setAuthURL(url string) {
|
||||
var popBrowser, keyExpired bool
|
||||
var recipient ipnauth.Actor
|
||||
|
||||
b.mu.Lock()
|
||||
switch {
|
||||
case url == "":
|
||||
b.resetAuthURLLocked()
|
||||
b.mu.Unlock()
|
||||
return
|
||||
case b.authURL != url:
|
||||
b.authURL = url
|
||||
b.authURLTime = b.clock.Now()
|
||||
@@ -2908,26 +2993,27 @@ func (b *LocalBackend) setAuthURL(url string) {
|
||||
popBrowser = true
|
||||
default:
|
||||
// Otherwise, only open it if the user explicitly requests interactive login.
|
||||
popBrowser = b.interact
|
||||
popBrowser = b.authActor != nil
|
||||
}
|
||||
keyExpired = b.keyExpired
|
||||
recipient = b.authActor // or nil
|
||||
// Consume the StartLoginInteractive call, if any, that caused the control
|
||||
// plane to send us this URL.
|
||||
b.interact = false
|
||||
b.authActor = nil
|
||||
b.mu.Unlock()
|
||||
|
||||
if popBrowser {
|
||||
b.popBrowserAuthNow(url, keyExpired)
|
||||
b.popBrowserAuthNow(url, keyExpired, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
// popBrowserAuthNow shuts down the data plane and sends an auth URL
|
||||
// to the connected frontend, if any.
|
||||
// popBrowserAuthNow shuts down the data plane and sends the URL to the recipient's
|
||||
// [watchSession]s if the recipient is non-nil; otherwise, it sends the URL to all watchSessions.
|
||||
// keyExpired is the value of b.keyExpired upon entry and indicates
|
||||
// whether the node's key has expired.
|
||||
// It must not be called with b.mu held.
|
||||
func (b *LocalBackend) popBrowserAuthNow(url string, keyExpired bool) {
|
||||
b.logf("popBrowserAuthNow: url=%v, key-expired=%v, seamless-key-renewal=%v", url != "", keyExpired, b.seamlessRenewalEnabled())
|
||||
func (b *LocalBackend) popBrowserAuthNow(url string, keyExpired bool, recipient ipnauth.Actor) {
|
||||
b.logf("popBrowserAuthNow(%q): url=%v, key-expired=%v, seamless-key-renewal=%v", maybeUsernameOf(recipient), url != "", keyExpired, b.seamlessRenewalEnabled())
|
||||
|
||||
// Deconfigure the local network data plane if:
|
||||
// - seamless key renewal is not enabled;
|
||||
@@ -2936,7 +3022,7 @@ func (b *LocalBackend) popBrowserAuthNow(url string, keyExpired bool) {
|
||||
b.blockEngineUpdates(true)
|
||||
b.stopEngineAndWait()
|
||||
}
|
||||
b.tellClientToBrowseToURL(url)
|
||||
b.tellRecipientToBrowseToURL(url, toNotificationTarget(recipient))
|
||||
if b.State() == ipn.Running {
|
||||
b.enterState(ipn.Starting)
|
||||
}
|
||||
@@ -2977,8 +3063,13 @@ func (b *LocalBackend) validPopBrowserURL(urlStr string) bool {
|
||||
}
|
||||
|
||||
func (b *LocalBackend) tellClientToBrowseToURL(url string) {
|
||||
b.tellRecipientToBrowseToURL(url, allClients)
|
||||
}
|
||||
|
||||
// tellRecipientToBrowseToURL is like tellClientToBrowseToURL but allows specifying a recipient.
|
||||
func (b *LocalBackend) tellRecipientToBrowseToURL(url string, recipient notificationTarget) {
|
||||
if b.validPopBrowserURL(url) {
|
||||
b.send(ipn.Notify{BrowseToURL: &url})
|
||||
b.sendTo(ipn.Notify{BrowseToURL: &url}, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3250,6 +3341,15 @@ func (b *LocalBackend) tryLookupUserName(uid string) string {
|
||||
// StartLoginInteractive attempts to pick up the in-progress flow where it left
|
||||
// off.
|
||||
func (b *LocalBackend) StartLoginInteractive(ctx context.Context) error {
|
||||
return b.StartLoginInteractiveAs(ctx, nil)
|
||||
}
|
||||
|
||||
// StartLoginInteractiveAs is like StartLoginInteractive but takes an [ipnauth.Actor]
|
||||
// as an additional parameter. If non-nil, the specified user is expected to complete
|
||||
// the interactive login, and therefore will receive the BrowseToURL notification once
|
||||
// the control plane sends us one. Otherwise, the notification will be delivered to all
|
||||
// active [watchSession]s.
|
||||
func (b *LocalBackend) StartLoginInteractiveAs(ctx context.Context, user ipnauth.Actor) error {
|
||||
b.mu.Lock()
|
||||
if b.cc == nil {
|
||||
panic("LocalBackend.assertClient: b.cc == nil")
|
||||
@@ -3263,17 +3363,17 @@ func (b *LocalBackend) StartLoginInteractive(ctx context.Context) error {
|
||||
hasValidURL := url != "" && timeSinceAuthURLCreated < ((7*24*time.Hour)-(1*time.Hour))
|
||||
if !hasValidURL {
|
||||
// A user wants to log in interactively, but we don't have a valid authURL.
|
||||
// Set a flag to indicate that interactive login is in progress, forcing
|
||||
// a BrowseToURL notification once the authURL becomes available.
|
||||
b.interact = true
|
||||
// Remember the user who initiated the login, so that we can notify them
|
||||
// once the authURL is available.
|
||||
b.authActor = user
|
||||
}
|
||||
cc := b.cc
|
||||
b.mu.Unlock()
|
||||
|
||||
b.logf("StartLoginInteractive: url=%v", hasValidURL)
|
||||
b.logf("StartLoginInteractiveAs(%q): url=%v", maybeUsernameOf(user), hasValidURL)
|
||||
|
||||
if hasValidURL {
|
||||
b.popBrowserAuthNow(url, keyExpired)
|
||||
b.popBrowserAuthNow(url, keyExpired, user)
|
||||
} else {
|
||||
cc.Login(b.loginFlags | controlclient.LoginInteractive)
|
||||
}
|
||||
@@ -3484,7 +3584,7 @@ func (b *LocalBackend) checkSSHPrefsLocked(p *ipn.Prefs) error {
|
||||
if !p.RunSSH {
|
||||
return nil
|
||||
}
|
||||
if err := envknob.CanRunTailscaleSSH(); err != nil {
|
||||
if err := featureknob.CanRunTailscaleSSH(); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
@@ -3565,6 +3665,10 @@ func updateExitNodeUsageWarning(p ipn.PrefsView, state *netmon.State, healthTrac
|
||||
}
|
||||
|
||||
func (b *LocalBackend) checkExitNodePrefsLocked(p *ipn.Prefs) error {
|
||||
if err := featureknob.CanUseExitNode(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if (p.ExitNodeIP.IsValid() || p.ExitNodeID != "") && p.AdvertisesExitNode() {
|
||||
return errors.New("Cannot advertise an exit node and use an exit node at the same time.")
|
||||
}
|
||||
@@ -4090,7 +4194,11 @@ func (b *LocalBackend) authReconfig() {
|
||||
disableSubnetsIfPAC := nm.HasCap(tailcfg.NodeAttrDisableSubnetsIfPAC)
|
||||
userDialUseRoutes := nm.HasCap(tailcfg.NodeAttrUserDialUseRoutes)
|
||||
dohURL, dohURLOK := exitNodeCanProxyDNS(nm, b.peers, prefs.ExitNodeID())
|
||||
dcfg := dnsConfigForNetmap(nm, b.peers, prefs, b.keyExpired, b.logf, version.OS())
|
||||
var forceAAAA bool
|
||||
if dm, ok := b.sys.DNSManager.GetOK(); ok {
|
||||
forceAAAA = dm.GetForceAAAA()
|
||||
}
|
||||
dcfg := dnsConfigForNetmap(nm, b.peers, prefs, b.keyExpired, forceAAAA, b.logf, version.OS())
|
||||
// If the current node is an app connector, ensure the app connector machine is started
|
||||
b.reconfigAppConnectorLocked(nm, prefs)
|
||||
b.mu.Unlock()
|
||||
@@ -4190,7 +4298,7 @@ func shouldUseOneCGNATRoute(logf logger.Logf, controlKnobs *controlknobs.Knobs,
|
||||
//
|
||||
// The versionOS is a Tailscale-style version ("iOS", "macOS") and not
|
||||
// a runtime.GOOS.
|
||||
func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, versionOS string) *dns.Config {
|
||||
func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired, forceAAAA bool, logf logger.Logf, versionOS string) *dns.Config {
|
||||
if nm == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -4253,7 +4361,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
|
||||
// https://github.com/tailscale/tailscale/issues/1152
|
||||
// tracks adding the right capability reporting to
|
||||
// enable AAAA in MagicDNS.
|
||||
if addr.Addr().Is6() && have4 {
|
||||
if addr.Addr().Is6() && have4 && !forceAAAA {
|
||||
continue
|
||||
}
|
||||
ips = append(ips, addr.Addr())
|
||||
@@ -5119,7 +5227,7 @@ func (b *LocalBackend) resetControlClientLocked() controlclient.Client {
|
||||
func (b *LocalBackend) resetAuthURLLocked() {
|
||||
b.authURL = ""
|
||||
b.authURLTime = time.Time{}
|
||||
b.interact = false
|
||||
b.authActor = nil
|
||||
}
|
||||
|
||||
// ResetForClientDisconnect resets the backend for GUI clients running
|
||||
@@ -5386,7 +5494,6 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
// If there is no netmap, the client is going into a "turned off"
|
||||
// state so reset the metrics.
|
||||
b.metrics.approvedRoutes.Set(0)
|
||||
b.metrics.primaryRoutes.Set(0)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5415,7 +5522,6 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
}
|
||||
}
|
||||
b.metrics.approvedRoutes.Set(approved)
|
||||
b.metrics.primaryRoutes.Set(float64(tsaddr.WithoutExitRoute(nm.SelfNode.PrimaryRoutes()).Len()))
|
||||
}
|
||||
for _, p := range nm.Peers {
|
||||
addNode(p)
|
||||
@@ -7364,3 +7470,13 @@ func (b *LocalBackend) srcIPHasCapForFilter(srcIP netip.Addr, cap tailcfg.NodeCa
|
||||
}
|
||||
return n.HasCap(cap)
|
||||
}
|
||||
|
||||
// maybeUsernameOf returns the actor's username if the actor
|
||||
// is non-nil and its username can be resolved.
|
||||
func maybeUsernameOf(actor ipnauth.Actor) string {
|
||||
var username string
|
||||
if actor != nil {
|
||||
username, _ = actor.Username()
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -31,6 +33,8 @@ import (
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/ipn/ipnauth"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/net/netcheck"
|
||||
"tailscale.com/net/netmon"
|
||||
@@ -52,6 +56,8 @@ import (
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/util/syspolicy"
|
||||
"tailscale.com/util/syspolicy/setting"
|
||||
"tailscale.com/util/syspolicy/source"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/filter"
|
||||
"tailscale.com/wgengine/wgcfg"
|
||||
@@ -428,16 +434,25 @@ func (panicOnUseTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
}
|
||||
|
||||
func newTestLocalBackend(t testing.TB) *LocalBackend {
|
||||
return newTestLocalBackendWithSys(t, new(tsd.System))
|
||||
}
|
||||
|
||||
// newTestLocalBackendWithSys creates a new LocalBackend with the given tsd.System.
|
||||
// If the state store or engine are not set in sys, they will be set to a new
|
||||
// in-memory store and fake userspace engine, respectively.
|
||||
func newTestLocalBackendWithSys(t testing.TB, sys *tsd.System) *LocalBackend {
|
||||
var logf logger.Logf = logger.Discard
|
||||
sys := new(tsd.System)
|
||||
store := new(mem.Store)
|
||||
sys.Set(store)
|
||||
eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFakeUserspaceEngine: %v", err)
|
||||
if _, ok := sys.StateStore.GetOK(); !ok {
|
||||
sys.Set(new(mem.Store))
|
||||
}
|
||||
if _, ok := sys.Engine.GetOK(); !ok {
|
||||
eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFakeUserspaceEngine: %v", err)
|
||||
}
|
||||
t.Cleanup(eng.Close)
|
||||
sys.Set(eng)
|
||||
}
|
||||
t.Cleanup(eng.Close)
|
||||
sys.Set(eng)
|
||||
lb, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalBackend: %v", err)
|
||||
@@ -1283,7 +1298,7 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
|
||||
}
|
||||
|
||||
prefs := &ipn.Prefs{ExitNodeID: tc.exitNode, CorpDNS: true}
|
||||
got := dnsConfigForNetmap(nm, peersMap(tc.peers), prefs.View(), false, t.Logf, "")
|
||||
got := dnsConfigForNetmap(nm, peersMap(tc.peers), prefs.View(), false, false, t.Logf, "")
|
||||
if !resolversEqual(t, got.DefaultResolvers, tc.wantDefaultResolvers) {
|
||||
t.Errorf("DefaultResolvers: got %#v, want %#v", got.DefaultResolvers, tc.wantDefaultResolvers)
|
||||
}
|
||||
@@ -1557,94 +1572,6 @@ func dnsResponse(domain, address string) []byte {
|
||||
return must.Get(b.Finish())
|
||||
}
|
||||
|
||||
type errorSyspolicyHandler struct {
|
||||
t *testing.T
|
||||
err error
|
||||
key syspolicy.Key
|
||||
allowKeys map[syspolicy.Key]*string
|
||||
}
|
||||
|
||||
func (h *errorSyspolicyHandler) ReadString(key string) (string, error) {
|
||||
sk := syspolicy.Key(key)
|
||||
if _, ok := h.allowKeys[sk]; !ok {
|
||||
h.t.Errorf("ReadString: %q is not in list of permitted keys", h.key)
|
||||
}
|
||||
if sk == h.key {
|
||||
return "", h.err
|
||||
}
|
||||
return "", syspolicy.ErrNoSuchKey
|
||||
}
|
||||
|
||||
func (h *errorSyspolicyHandler) ReadUInt64(key string) (uint64, error) {
|
||||
h.t.Errorf("ReadUInt64(%q) unexpectedly called", key)
|
||||
return 0, syspolicy.ErrNoSuchKey
|
||||
}
|
||||
|
||||
func (h *errorSyspolicyHandler) ReadBoolean(key string) (bool, error) {
|
||||
h.t.Errorf("ReadBoolean(%q) unexpectedly called", key)
|
||||
return false, syspolicy.ErrNoSuchKey
|
||||
}
|
||||
|
||||
func (h *errorSyspolicyHandler) ReadStringArray(key string) ([]string, error) {
|
||||
h.t.Errorf("ReadStringArray(%q) unexpectedly called", key)
|
||||
return nil, syspolicy.ErrNoSuchKey
|
||||
}
|
||||
|
||||
type mockSyspolicyHandler struct {
|
||||
t *testing.T
|
||||
// stringPolicies is the collection of policies that we expect to see
|
||||
// queried by the current test. If the policy is expected but unset, then
|
||||
// use nil, otherwise use a string equal to the policy's desired value.
|
||||
stringPolicies map[syspolicy.Key]*string
|
||||
// stringArrayPolicies is the collection of policies that we expected to see
|
||||
// queries by the current test, that return policy string arrays.
|
||||
stringArrayPolicies map[syspolicy.Key][]string
|
||||
// failUnknownPolicies is set if policies other than those in stringPolicies
|
||||
// (uint64 or bool policies are not supported by mockSyspolicyHandler yet)
|
||||
// should be considered a test failure if they are queried.
|
||||
failUnknownPolicies bool
|
||||
}
|
||||
|
||||
func (h *mockSyspolicyHandler) ReadString(key string) (string, error) {
|
||||
if s, ok := h.stringPolicies[syspolicy.Key(key)]; ok {
|
||||
if s == nil {
|
||||
return "", syspolicy.ErrNoSuchKey
|
||||
}
|
||||
return *s, nil
|
||||
}
|
||||
if h.failUnknownPolicies {
|
||||
h.t.Errorf("ReadString(%q) unexpectedly called", key)
|
||||
}
|
||||
return "", syspolicy.ErrNoSuchKey
|
||||
}
|
||||
|
||||
func (h *mockSyspolicyHandler) ReadUInt64(key string) (uint64, error) {
|
||||
if h.failUnknownPolicies {
|
||||
h.t.Errorf("ReadUInt64(%q) unexpectedly called", key)
|
||||
}
|
||||
return 0, syspolicy.ErrNoSuchKey
|
||||
}
|
||||
|
||||
func (h *mockSyspolicyHandler) ReadBoolean(key string) (bool, error) {
|
||||
if h.failUnknownPolicies {
|
||||
h.t.Errorf("ReadBoolean(%q) unexpectedly called", key)
|
||||
}
|
||||
return false, syspolicy.ErrNoSuchKey
|
||||
}
|
||||
|
||||
func (h *mockSyspolicyHandler) ReadStringArray(key string) ([]string, error) {
|
||||
if h.failUnknownPolicies {
|
||||
h.t.Errorf("ReadStringArray(%q) unexpectedly called", key)
|
||||
}
|
||||
if s, ok := h.stringArrayPolicies[syspolicy.Key(key)]; ok {
|
||||
if s == nil {
|
||||
return []string{}, syspolicy.ErrNoSuchKey
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
return nil, syspolicy.ErrNoSuchKey
|
||||
}
|
||||
|
||||
func TestSetExitNodeIDPolicy(t *testing.T) {
|
||||
pfx := netip.MustParsePrefix
|
||||
tests := []struct {
|
||||
@@ -1854,23 +1781,18 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
syspolicy.RegisterWellKnownSettingsForTest(t)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
msh := &mockSyspolicyHandler{
|
||||
t: t,
|
||||
stringPolicies: map[syspolicy.Key]*string{
|
||||
syspolicy.ExitNodeID: nil,
|
||||
syspolicy.ExitNodeIP: nil,
|
||||
},
|
||||
}
|
||||
if test.exitNodeIDKey {
|
||||
msh.stringPolicies[syspolicy.ExitNodeID] = &test.exitNodeID
|
||||
}
|
||||
if test.exitNodeIPKey {
|
||||
msh.stringPolicies[syspolicy.ExitNodeIP] = &test.exitNodeIP
|
||||
}
|
||||
syspolicy.SetHandlerForTest(t, msh)
|
||||
|
||||
policyStore := source.NewTestStoreOf(t,
|
||||
source.TestSettingOf(syspolicy.ExitNodeID, test.exitNodeID),
|
||||
source.TestSettingOf(syspolicy.ExitNodeIP, test.exitNodeIP),
|
||||
)
|
||||
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
|
||||
|
||||
if test.nm == nil {
|
||||
test.nm = new(netmap.NetworkMap)
|
||||
}
|
||||
@@ -1992,13 +1914,13 @@ func TestUpdateNetmapDeltaAutoExitNode(t *testing.T) {
|
||||
report: report,
|
||||
},
|
||||
}
|
||||
msh := &mockSyspolicyHandler{
|
||||
t: t,
|
||||
stringPolicies: map[syspolicy.Key]*string{
|
||||
syspolicy.ExitNodeID: ptr.To("auto:any"),
|
||||
},
|
||||
}
|
||||
syspolicy.SetHandlerForTest(t, msh)
|
||||
|
||||
syspolicy.RegisterWellKnownSettingsForTest(t)
|
||||
policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
|
||||
syspolicy.ExitNodeID, "auto:any",
|
||||
))
|
||||
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := newTestLocalBackend(t)
|
||||
@@ -2047,13 +1969,11 @@ func TestAutoExitNodeSetNetInfoCallback(t *testing.T) {
|
||||
}
|
||||
cc = newClient(t, opts)
|
||||
b.cc = cc
|
||||
msh := &mockSyspolicyHandler{
|
||||
t: t,
|
||||
stringPolicies: map[syspolicy.Key]*string{
|
||||
syspolicy.ExitNodeID: ptr.To("auto:any"),
|
||||
},
|
||||
}
|
||||
syspolicy.SetHandlerForTest(t, msh)
|
||||
syspolicy.RegisterWellKnownSettingsForTest(t)
|
||||
policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
|
||||
syspolicy.ExitNodeID, "auto:any",
|
||||
))
|
||||
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
|
||||
peer1 := makePeer(1, withCap(26), withDERP(3), withSuggest(), withExitRoutes())
|
||||
peer2 := makePeer(2, withCap(26), withDERP(2), withSuggest(), withExitRoutes())
|
||||
selfNode := tailcfg.Node{
|
||||
@@ -2158,13 +2078,11 @@ func TestSetControlClientStatusAutoExitNode(t *testing.T) {
|
||||
DERPMap: derpMap,
|
||||
}
|
||||
b := newTestLocalBackend(t)
|
||||
msh := &mockSyspolicyHandler{
|
||||
t: t,
|
||||
stringPolicies: map[syspolicy.Key]*string{
|
||||
syspolicy.ExitNodeID: ptr.To("auto:any"),
|
||||
},
|
||||
}
|
||||
syspolicy.SetHandlerForTest(t, msh)
|
||||
syspolicy.RegisterWellKnownSettingsForTest(t)
|
||||
policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
|
||||
syspolicy.ExitNodeID, "auto:any",
|
||||
))
|
||||
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
|
||||
b.netMap = nm
|
||||
b.lastSuggestedExitNode = peer1.StableID()
|
||||
b.sys.MagicSock.Get().SetLastNetcheckReportForTest(b.ctx, report)
|
||||
@@ -2398,17 +2316,16 @@ func TestApplySysPolicy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
syspolicy.RegisterWellKnownSettingsForTest(t)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msh := &mockSyspolicyHandler{
|
||||
t: t,
|
||||
stringPolicies: make(map[syspolicy.Key]*string, len(tt.stringPolicies)),
|
||||
}
|
||||
settings := make([]source.TestSetting[string], 0, len(tt.stringPolicies))
|
||||
for p, v := range tt.stringPolicies {
|
||||
v := v // construct a unique pointer for each policy value
|
||||
msh.stringPolicies[p] = &v
|
||||
settings = append(settings, source.TestSettingOf(p, v))
|
||||
}
|
||||
syspolicy.SetHandlerForTest(t, msh)
|
||||
policyStore := source.NewTestStoreOf(t, settings...)
|
||||
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
|
||||
|
||||
t.Run("unit", func(t *testing.T) {
|
||||
prefs := tt.prefs.Clone()
|
||||
@@ -2544,35 +2461,19 @@ func TestPreferencePolicyInfo(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
syspolicy.RegisterWellKnownSettingsForTest(t)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, pp := range preferencePolicies {
|
||||
t.Run(string(pp.key), func(t *testing.T) {
|
||||
var h syspolicy.Handler
|
||||
|
||||
allPolicies := make(map[syspolicy.Key]*string, len(preferencePolicies)+1)
|
||||
allPolicies[syspolicy.ControlURL] = nil
|
||||
for _, pp := range preferencePolicies {
|
||||
allPolicies[pp.key] = nil
|
||||
s := source.TestSetting[string]{
|
||||
Key: pp.key,
|
||||
Error: tt.policyError,
|
||||
Value: tt.policyValue,
|
||||
}
|
||||
|
||||
if tt.policyError != nil {
|
||||
h = &errorSyspolicyHandler{
|
||||
t: t,
|
||||
err: tt.policyError,
|
||||
key: pp.key,
|
||||
allowKeys: allPolicies,
|
||||
}
|
||||
} else {
|
||||
msh := &mockSyspolicyHandler{
|
||||
t: t,
|
||||
stringPolicies: allPolicies,
|
||||
failUnknownPolicies: true,
|
||||
}
|
||||
msh.stringPolicies[pp.key] = &tt.policyValue
|
||||
h = msh
|
||||
}
|
||||
syspolicy.SetHandlerForTest(t, h)
|
||||
policyStore := source.NewTestStoreOf(t, s)
|
||||
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
|
||||
|
||||
prefs := defaultPrefs.AsStruct()
|
||||
pp.set(prefs, tt.initialValue)
|
||||
@@ -3823,15 +3724,16 @@ func TestShouldAutoExitNode(t *testing.T) {
|
||||
expectedBool: false,
|
||||
},
|
||||
}
|
||||
|
||||
syspolicy.RegisterWellKnownSettingsForTest(t)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msh := &mockSyspolicyHandler{
|
||||
t: t,
|
||||
stringPolicies: map[syspolicy.Key]*string{
|
||||
syspolicy.ExitNodeID: ptr.To(tt.exitNodeIDPolicyValue),
|
||||
},
|
||||
}
|
||||
syspolicy.SetHandlerForTest(t, msh)
|
||||
policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
|
||||
syspolicy.ExitNodeID, tt.exitNodeIDPolicyValue,
|
||||
))
|
||||
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
|
||||
|
||||
got := shouldAutoExitNode()
|
||||
if got != tt.expectedBool {
|
||||
t.Fatalf("expected %v got %v for %v policy value", tt.expectedBool, got, tt.exitNodeIDPolicyValue)
|
||||
@@ -3969,17 +3871,13 @@ func TestFillAllowedSuggestions(t *testing.T) {
|
||||
want: []tailcfg.StableNodeID{"ABC", "def", "gHiJ"},
|
||||
},
|
||||
}
|
||||
syspolicy.RegisterWellKnownSettingsForTest(t)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mh := mockSyspolicyHandler{
|
||||
t: t,
|
||||
}
|
||||
if tt.allowPolicy != nil {
|
||||
mh.stringArrayPolicies = map[syspolicy.Key][]string{
|
||||
syspolicy.AllowedSuggestedExitNodes: tt.allowPolicy,
|
||||
}
|
||||
}
|
||||
syspolicy.SetHandlerForTest(t, &mh)
|
||||
policyStore := source.NewTestStoreOf(t, source.TestSettingOf(
|
||||
syspolicy.AllowedSuggestedExitNodes, tt.allowPolicy,
|
||||
))
|
||||
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
|
||||
|
||||
got := fillAllowedSuggestions()
|
||||
if got == nil {
|
||||
@@ -3998,3 +3896,573 @@ func TestFillAllowedSuggestions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationTargetMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target notificationTarget
|
||||
actor ipnauth.Actor
|
||||
wantMatch bool
|
||||
}{
|
||||
{
|
||||
name: "AllClients/Nil",
|
||||
target: allClients,
|
||||
actor: nil,
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "AllClients/NoUID/NoCID",
|
||||
target: allClients,
|
||||
actor: &ipnauth.TestActor{},
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "AllClients/WithUID/NoCID",
|
||||
target: allClients,
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.NoClientID},
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "AllClients/NoUID/WithCID",
|
||||
target: allClients,
|
||||
actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "AllClients/WithUID/WithCID",
|
||||
target: allClients,
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID/Nil",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4"},
|
||||
actor: nil,
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID/NoUID/NoCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4"},
|
||||
actor: &ipnauth.TestActor{},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID/NoUID/WithCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4"},
|
||||
actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID/SameUID/NoCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4"},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4"},
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID/DifferentUID/NoCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4"},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8"},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID/SameUID/WithCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4"},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID/DifferentUID/WithCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4"},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8", CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByCID/Nil",
|
||||
target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: nil,
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByCID/NoUID/NoCID",
|
||||
target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByCID/NoUID/SameCID",
|
||||
target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "FilterByCID/NoUID/DifferentCID",
|
||||
target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("B")},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByCID/WithUID/NoCID",
|
||||
target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4"},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByCID/WithUID/SameCID",
|
||||
target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "FilterByCID/WithUID/DifferentCID",
|
||||
target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("B")},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/Nil",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4"},
|
||||
actor: nil,
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/NoUID/NoCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/NoUID/SameCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/NoUID/DifferentCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("B")},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/SameUID/NoCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4"},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/SameUID/SameCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/SameUID/DifferentCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("B")},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/DifferentUID/NoCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8"},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/DifferentUID/SameCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8", CID: ipnauth.ClientIDFrom("A")},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "FilterByUID+CID/DifferentUID/DifferentCID",
|
||||
target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")},
|
||||
actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8", CID: ipnauth.ClientIDFrom("B")},
|
||||
wantMatch: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotMatch := tt.target.match(tt.actor)
|
||||
if gotMatch != tt.wantMatch {
|
||||
t.Errorf("match: got %v; want %v", gotMatch, tt.wantMatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type newTestControlFn func(tb testing.TB, opts controlclient.Options) controlclient.Client
|
||||
|
||||
func newLocalBackendWithTestControl(t *testing.T, enableLogging bool, newControl newTestControlFn) *LocalBackend {
|
||||
logf := logger.Discard
|
||||
if enableLogging {
|
||||
logf = tstest.WhileTestRunningLogger(t)
|
||||
}
|
||||
sys := new(tsd.System)
|
||||
store := new(mem.Store)
|
||||
sys.Set(store)
|
||||
e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFakeUserspaceEngine: %v", err)
|
||||
}
|
||||
t.Cleanup(e.Close)
|
||||
sys.Set(e)
|
||||
|
||||
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalBackend: %v", err)
|
||||
}
|
||||
b.DisablePortMapperForTest()
|
||||
|
||||
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {
|
||||
return newControl(t, opts), nil
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// notificationHandler is any function that can process (e.g., check) a notification.
|
||||
// It returns whether the notification has been handled or should be passed to the next handler.
|
||||
// The handler may be called from any goroutine, so it must avoid calling functions
|
||||
// that are restricted to the goroutine running the test or benchmark function,
|
||||
// such as [testing.common.FailNow] and [testing.common.Fatalf].
|
||||
type notificationHandler func(testing.TB, ipnauth.Actor, *ipn.Notify) bool
|
||||
|
||||
// wantedNotification names a [notificationHandler] that processes a notification
|
||||
// the test expects and wants to receive. The name is used to report notifications
|
||||
// that haven't been received within the expected timeout.
|
||||
type wantedNotification struct {
|
||||
name string
|
||||
cond notificationHandler
|
||||
}
|
||||
|
||||
// notificationWatcher observes [LocalBackend] notifications as the specified actor,
|
||||
// reporting missing but expected notifications using [testing.common.Error],
|
||||
// and delegating the handling of unexpected notifications to the [notificationHandler]s.
|
||||
type notificationWatcher struct {
|
||||
tb testing.TB
|
||||
lb *LocalBackend
|
||||
actor ipnauth.Actor
|
||||
|
||||
mu sync.Mutex
|
||||
mask ipn.NotifyWatchOpt
|
||||
want []wantedNotification // notifications we want to receive
|
||||
unexpected []notificationHandler // funcs that are called to check any other notifications
|
||||
ctxCancel context.CancelFunc // cancels the outstanding [LocalBackend.WatchNotificationsAs] call
|
||||
got []*ipn.Notify // all notifications, both wanted and unexpected, we've received so far
|
||||
gotWanted []*ipn.Notify // only the expected notifications; holds nil for any notification that hasn't been received
|
||||
gotWantedCh chan struct{} // closed when we have received the last wanted notification
|
||||
doneCh chan struct{} // closed when [LocalBackend.WatchNotificationsAs] returns
|
||||
}
|
||||
|
||||
func newNotificationWatcher(tb testing.TB, lb *LocalBackend, actor ipnauth.Actor) *notificationWatcher {
|
||||
return ¬ificationWatcher{tb: tb, lb: lb, actor: actor}
|
||||
}
|
||||
|
||||
func (w *notificationWatcher) watch(mask ipn.NotifyWatchOpt, wanted []wantedNotification, unexpected ...notificationHandler) {
|
||||
w.tb.Helper()
|
||||
|
||||
// Cancel any outstanding [LocalBackend.WatchNotificationsAs] calls.
|
||||
w.mu.Lock()
|
||||
ctxCancel := w.ctxCancel
|
||||
doneCh := w.doneCh
|
||||
w.mu.Unlock()
|
||||
if doneCh != nil {
|
||||
ctxCancel()
|
||||
<-doneCh
|
||||
}
|
||||
|
||||
doneCh = make(chan struct{})
|
||||
gotWantedCh := make(chan struct{})
|
||||
ctx, ctxCancel := context.WithCancel(context.Background())
|
||||
w.tb.Cleanup(func() {
|
||||
ctxCancel()
|
||||
<-doneCh
|
||||
})
|
||||
|
||||
w.mu.Lock()
|
||||
w.mask = mask
|
||||
w.want = wanted
|
||||
w.unexpected = unexpected
|
||||
w.ctxCancel = ctxCancel
|
||||
w.got = nil
|
||||
w.gotWanted = make([]*ipn.Notify, len(wanted))
|
||||
w.gotWantedCh = gotWantedCh
|
||||
w.doneCh = doneCh
|
||||
w.mu.Unlock()
|
||||
|
||||
watchAddedCh := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneCh)
|
||||
if len(wanted) == 0 {
|
||||
close(gotWantedCh)
|
||||
if len(unexpected) == 0 {
|
||||
close(watchAddedCh)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var nextWantIdx int
|
||||
w.lb.WatchNotificationsAs(ctx, w.actor, w.mask, func() { close(watchAddedCh) }, func(notify *ipn.Notify) (keepGoing bool) {
|
||||
w.tb.Helper()
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.got = append(w.got, notify)
|
||||
|
||||
wanted := false
|
||||
for i := nextWantIdx; i < len(w.want); i++ {
|
||||
if wanted = w.want[i].cond(w.tb, w.actor, notify); wanted {
|
||||
w.gotWanted[i] = notify
|
||||
nextWantIdx = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if wanted && nextWantIdx == len(w.want) {
|
||||
close(w.gotWantedCh)
|
||||
if len(w.unexpected) == 0 {
|
||||
// If we have received the last wanted notification,
|
||||
// and we don't have any handlers for the unexpected notifications,
|
||||
// we can stop the watcher right away.
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if !wanted {
|
||||
// If we've received a notification we didn't expect,
|
||||
// it could either be an unwanted notification caused by a bug
|
||||
// or just a miscellaneous one that's irrelevant for the current test.
|
||||
// Call unexpected notification handlers, if any, to
|
||||
// check and fail the test if necessary.
|
||||
for _, h := range w.unexpected {
|
||||
if h(w.tb, w.actor, notify) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
}()
|
||||
<-watchAddedCh
|
||||
}
|
||||
|
||||
func (w *notificationWatcher) check() []*ipn.Notify {
|
||||
w.tb.Helper()
|
||||
|
||||
w.mu.Lock()
|
||||
cancel := w.ctxCancel
|
||||
gotWantedCh := w.gotWantedCh
|
||||
checkUnexpected := len(w.unexpected) != 0
|
||||
doneCh := w.doneCh
|
||||
w.mu.Unlock()
|
||||
|
||||
// Wait for up to 10 seconds to receive expected notifications.
|
||||
timeout := 10 * time.Second
|
||||
for {
|
||||
select {
|
||||
case <-gotWantedCh:
|
||||
if checkUnexpected {
|
||||
gotWantedCh = nil
|
||||
// But do not wait longer than 500ms for unexpected notifications after
|
||||
// the expected notifications have been received.
|
||||
timeout = 500 * time.Millisecond
|
||||
continue
|
||||
}
|
||||
case <-doneCh:
|
||||
// [LocalBackend.WatchNotificationsAs] has already returned, so no further
|
||||
// notifications will be received. There's no reason to wait any longer.
|
||||
case <-time.After(timeout):
|
||||
}
|
||||
cancel()
|
||||
<-doneCh
|
||||
break
|
||||
}
|
||||
|
||||
// Report missing notifications, if any, and log all received notifications,
|
||||
// including both expected and unexpected ones.
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if hasMissing := slices.Contains(w.gotWanted, nil); hasMissing {
|
||||
want := make([]string, len(w.want))
|
||||
got := make([]string, 0, len(w.want))
|
||||
for i, wn := range w.want {
|
||||
want[i] = wn.name
|
||||
if w.gotWanted[i] != nil {
|
||||
got = append(got, wn.name)
|
||||
}
|
||||
}
|
||||
w.tb.Errorf("Notifications(%s): got %q; want %q", actorDescriptionForTest(w.actor), strings.Join(got, ", "), strings.Join(want, ", "))
|
||||
for i, n := range w.got {
|
||||
w.tb.Logf("%d. %v", i, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return w.gotWanted
|
||||
}
|
||||
|
||||
func actorDescriptionForTest(actor ipnauth.Actor) string {
|
||||
var parts []string
|
||||
if actor != nil {
|
||||
if name, _ := actor.Username(); name != "" {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
if uid := actor.UserID(); uid != "" {
|
||||
parts = append(parts, string(uid))
|
||||
}
|
||||
if clientID, _ := actor.ClientID(); clientID != ipnauth.NoClientID {
|
||||
parts = append(parts, clientID.String())
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("Actor{%s}", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
func TestLoginNotifications(t *testing.T) {
|
||||
const (
|
||||
enableLogging = true
|
||||
controlURL = "https://localhost:1/"
|
||||
loginURL = "https://localhost:1/1"
|
||||
)
|
||||
|
||||
wantBrowseToURL := wantedNotification{
|
||||
name: "BrowseToURL",
|
||||
cond: func(t testing.TB, actor ipnauth.Actor, n *ipn.Notify) bool {
|
||||
if n.BrowseToURL != nil && *n.BrowseToURL != loginURL {
|
||||
t.Errorf("BrowseToURL (%s): got %q; want %q", actorDescriptionForTest(actor), *n.BrowseToURL, loginURL)
|
||||
return false
|
||||
}
|
||||
return n.BrowseToURL != nil
|
||||
},
|
||||
}
|
||||
unexpectedBrowseToURL := func(t testing.TB, actor ipnauth.Actor, n *ipn.Notify) bool {
|
||||
if n.BrowseToURL != nil {
|
||||
t.Errorf("Unexpected BrowseToURL(%s): %v", actorDescriptionForTest(actor), n)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
logInAs ipnauth.Actor
|
||||
urlExpectedBy []ipnauth.Actor
|
||||
urlUnexpectedBy []ipnauth.Actor
|
||||
}{
|
||||
{
|
||||
name: "NoObservers",
|
||||
logInAs: &ipnauth.TestActor{UID: "A"},
|
||||
urlExpectedBy: []ipnauth.Actor{}, // ensure that it does not panic if no one is watching
|
||||
},
|
||||
{
|
||||
name: "SingleUser",
|
||||
logInAs: &ipnauth.TestActor{UID: "A"},
|
||||
urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A"}},
|
||||
},
|
||||
{
|
||||
name: "SameUser/TwoSessions/NoCID",
|
||||
logInAs: &ipnauth.TestActor{UID: "A"},
|
||||
urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A"}, &ipnauth.TestActor{UID: "A"}},
|
||||
},
|
||||
{
|
||||
name: "SameUser/TwoSessions/OneWithCID",
|
||||
logInAs: &ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")},
|
||||
urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")}},
|
||||
urlUnexpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A"}},
|
||||
},
|
||||
{
|
||||
name: "SameUser/TwoSessions/BothWithCID",
|
||||
logInAs: &ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")},
|
||||
urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")}},
|
||||
urlUnexpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("456")}},
|
||||
},
|
||||
{
|
||||
name: "DifferentUsers/NoCID",
|
||||
logInAs: &ipnauth.TestActor{UID: "A"},
|
||||
urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A"}},
|
||||
urlUnexpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "B"}},
|
||||
},
|
||||
{
|
||||
name: "DifferentUsers/SameCID",
|
||||
logInAs: &ipnauth.TestActor{UID: "A"},
|
||||
urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")}},
|
||||
urlUnexpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "B", CID: ipnauth.ClientIDFrom("123")}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lb := newLocalBackendWithTestControl(t, enableLogging, func(tb testing.TB, opts controlclient.Options) controlclient.Client {
|
||||
return newClient(tb, opts)
|
||||
})
|
||||
if _, err := lb.EditPrefs(&ipn.MaskedPrefs{ControlURLSet: true, Prefs: ipn.Prefs{ControlURL: controlURL}}); err != nil {
|
||||
t.Fatalf("(*EditPrefs).Start(): %v", err)
|
||||
}
|
||||
if err := lb.Start(ipn.Options{}); err != nil {
|
||||
t.Fatalf("(*LocalBackend).Start(): %v", err)
|
||||
}
|
||||
|
||||
sessions := make([]*notificationWatcher, 0, len(tt.urlExpectedBy)+len(tt.urlUnexpectedBy))
|
||||
for _, actor := range tt.urlExpectedBy {
|
||||
session := newNotificationWatcher(t, lb, actor)
|
||||
session.watch(0, []wantedNotification{wantBrowseToURL})
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
for _, actor := range tt.urlUnexpectedBy {
|
||||
session := newNotificationWatcher(t, lb, actor)
|
||||
session.watch(0, nil, unexpectedBrowseToURL)
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
|
||||
if err := lb.StartLoginInteractiveAs(context.Background(), tt.logInAs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lb.cc.(*mockControl).send(nil, loginURL, false, nil)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(sessions))
|
||||
for _, sess := range sessions {
|
||||
go func() { // check all sessions in parallel
|
||||
sess.check()
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFileReload tests that the LocalBackend reloads its configuration
|
||||
// when the configuration file changes.
|
||||
func TestConfigFileReload(t *testing.T) {
|
||||
cfg1 := `{"Hostname": "foo", "Version": "alpha0"}`
|
||||
f := filepath.Join(t.TempDir(), "cfg")
|
||||
must.Do(os.WriteFile(f, []byte(cfg1), 0600))
|
||||
sys := new(tsd.System)
|
||||
sys.InitialConfig = must.Get(conffile.Load(f))
|
||||
lb := newTestLocalBackendWithSys(t, sys)
|
||||
must.Do(lb.Start(ipn.Options{}))
|
||||
|
||||
lb.mu.Lock()
|
||||
hn := lb.hostinfo.Hostname
|
||||
lb.mu.Unlock()
|
||||
if hn != "foo" {
|
||||
t.Fatalf("got %q; want %q", hn, "foo")
|
||||
}
|
||||
|
||||
cfg2 := `{"Hostname": "bar", "Version": "alpha0"}`
|
||||
must.Do(os.WriteFile(f, []byte(cfg2), 0600))
|
||||
if !must.Get(lb.ReloadConfig()) {
|
||||
t.Fatal("reload failed")
|
||||
}
|
||||
|
||||
lb.mu.Lock()
|
||||
hn = lb.hostinfo.Hostname
|
||||
lb.mu.Unlock()
|
||||
if hn != "bar" {
|
||||
t.Fatalf("got %q; want %q", hn, "bar")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type actor struct {
|
||||
logf logger.Logf
|
||||
ci *ipnauth.ConnIdentity
|
||||
|
||||
clientID ipnauth.ClientID
|
||||
isLocalSystem bool // whether the actor is the Windows' Local System identity.
|
||||
}
|
||||
|
||||
@@ -39,7 +40,22 @@ func newActor(logf logger.Logf, c net.Conn) (*actor, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &actor{logf: logf, ci: ci, isLocalSystem: connIsLocalSystem(ci)}, nil
|
||||
var clientID ipnauth.ClientID
|
||||
if pid := ci.Pid(); pid != 0 {
|
||||
// Derive [ipnauth.ClientID] from the PID of the connected client process.
|
||||
// TODO(nickkhyl): This is transient and will be re-worked as we
|
||||
// progress on tailscale/corp#18342. At minimum, we should use a 2-tuple
|
||||
// (PID + StartTime) or a 3-tuple (PID + StartTime + UID) to identify
|
||||
// the client process. This helps prevent security issues where a
|
||||
// terminated client process's PID could be reused by a different
|
||||
// process. This is not currently an issue as we allow only one user to
|
||||
// connect anyway.
|
||||
// Additionally, we should consider caching authentication results since
|
||||
// operations like retrieving a username by SID might require network
|
||||
// connectivity on domain-joined devices and/or be slow.
|
||||
clientID = ipnauth.ClientIDFrom(pid)
|
||||
}
|
||||
return &actor{logf: logf, ci: ci, clientID: clientID, isLocalSystem: connIsLocalSystem(ci)}, nil
|
||||
}
|
||||
|
||||
// IsLocalSystem implements [ipnauth.Actor].
|
||||
@@ -61,6 +77,11 @@ func (a *actor) pid() int {
|
||||
return a.ci.Pid()
|
||||
}
|
||||
|
||||
// ClientID implements [ipnauth.Actor].
|
||||
func (a *actor) ClientID() (_ ipnauth.ClientID, ok bool) {
|
||||
return a.clientID, a.clientID != ipnauth.NoClientID
|
||||
}
|
||||
|
||||
// Username implements [ipnauth.Actor].
|
||||
func (a *actor) Username() (string, error) {
|
||||
if a.ci == nil {
|
||||
|
||||
@@ -62,7 +62,8 @@ import (
|
||||
"tailscale.com/util/osdiag"
|
||||
"tailscale.com/util/progresstracking"
|
||||
"tailscale.com/util/rands"
|
||||
"tailscale.com/util/testenv"
|
||||
"tailscale.com/util/syspolicy/rsop"
|
||||
"tailscale.com/util/syspolicy/setting"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/wgengine/magicsock"
|
||||
)
|
||||
@@ -77,6 +78,7 @@ var handler = map[string]localAPIHandler{
|
||||
"cert/": (*Handler).serveCert,
|
||||
"file-put/": (*Handler).serveFilePut,
|
||||
"files/": (*Handler).serveFiles,
|
||||
"policy/": (*Handler).servePolicy,
|
||||
"profiles/": (*Handler).serveProfiles,
|
||||
|
||||
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME
|
||||
@@ -570,15 +572,9 @@ func (h *Handler) serveMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
clientmetric.WritePrometheusExpositionFormat(w)
|
||||
}
|
||||
|
||||
// TODO(kradalby): Remove this once we have landed on a final set of
|
||||
// metrics to export to clients and consider the metrics stable.
|
||||
var debugUsermetricsEndpoint = envknob.RegisterBool("TS_DEBUG_USER_METRICS")
|
||||
|
||||
// serveUserMetrics returns user-facing metrics in Prometheus text
|
||||
// exposition format.
|
||||
func (h *Handler) serveUserMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if !testenv.InTest() && !debugUsermetricsEndpoint() {
|
||||
http.Error(w, "usermetrics debug flag not enabled", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
h.b.UserMetricsRegistry().Handler(w, r)
|
||||
}
|
||||
|
||||
@@ -1231,7 +1227,7 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
ctx := r.Context()
|
||||
enc := json.NewEncoder(w)
|
||||
h.b.WatchNotifications(ctx, mask, f.Flush, func(roNotify *ipn.Notify) (keepGoing bool) {
|
||||
h.b.WatchNotificationsAs(ctx, h.Actor, mask, f.Flush, func(roNotify *ipn.Notify) (keepGoing bool) {
|
||||
err := enc.Encode(roNotify)
|
||||
if err != nil {
|
||||
h.logf("json.Encode: %v", err)
|
||||
@@ -1251,7 +1247,7 @@ func (h *Handler) serveLoginInteractive(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "want POST", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.b.StartLoginInteractive(r.Context())
|
||||
h.b.StartLoginInteractiveAs(r.Context(), h.Actor)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
@@ -1339,6 +1335,53 @@ func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) {
|
||||
e.Encode(prefs)
|
||||
}
|
||||
|
||||
func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitRead {
|
||||
http.Error(w, "policy access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
suffix, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/policy/")
|
||||
if !ok {
|
||||
http.Error(w, "misconfigured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var scope setting.PolicyScope
|
||||
if suffix == "" {
|
||||
scope = setting.DefaultScope()
|
||||
} else if err := scope.UnmarshalText([]byte(suffix)); err != nil {
|
||||
http.Error(w, fmt.Sprintf("%q is not a valid scope", suffix), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := rsop.PolicyFor(scope)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var effectivePolicy *setting.Snapshot
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
effectivePolicy = policy.Get()
|
||||
case "POST":
|
||||
effectivePolicy, err = policy.Reload()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", "\t")
|
||||
e.Encode(effectivePolicy)
|
||||
}
|
||||
|
||||
type resJSON struct {
|
||||
Error string `json:",omitempty"`
|
||||
}
|
||||
|
||||
@@ -39,23 +39,6 @@ import (
|
||||
"tailscale.com/wgengine"
|
||||
)
|
||||
|
||||
var _ ipnauth.Actor = (*testActor)(nil)
|
||||
|
||||
type testActor struct {
|
||||
uid ipn.WindowsUserID
|
||||
name string
|
||||
isLocalSystem bool
|
||||
isLocalAdmin bool
|
||||
}
|
||||
|
||||
func (u *testActor) UserID() ipn.WindowsUserID { return u.uid }
|
||||
|
||||
func (u *testActor) Username() (string, error) { return u.name, nil }
|
||||
|
||||
func (u *testActor) IsLocalSystem() bool { return u.isLocalSystem }
|
||||
|
||||
func (u *testActor) IsLocalAdmin(operatorUID string) bool { return u.isLocalAdmin }
|
||||
|
||||
func TestValidHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
@@ -207,7 +190,7 @@ func TestWhoIsArgTypes(t *testing.T) {
|
||||
|
||||
func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
|
||||
newHandler := func(connIsLocalAdmin bool) *Handler {
|
||||
return &Handler{Actor: &testActor{isLocalAdmin: connIsLocalAdmin}, b: newTestLocalBackend(t)}
|
||||
return &Handler{Actor: &ipnauth.TestActor{LocalAdmin: connIsLocalAdmin}, b: newTestLocalBackend(t)}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
11
ipn/prefs.go
11
ipn/prefs.go
@@ -179,6 +179,12 @@ type Prefs struct {
|
||||
// node.
|
||||
AdvertiseRoutes []netip.Prefix
|
||||
|
||||
// AdvertiseServices specifies the list of services that this
|
||||
// node can serve as a destination for. Note that an advertised
|
||||
// service must still go through the approval process from the
|
||||
// control server.
|
||||
AdvertiseServices []string
|
||||
|
||||
// NoSNAT specifies whether to source NAT traffic going to
|
||||
// destinations in AdvertiseRoutes. The default is to apply source
|
||||
// NAT, which makes the traffic appear to come from the router
|
||||
@@ -319,6 +325,7 @@ type MaskedPrefs struct {
|
||||
ForceDaemonSet bool `json:",omitempty"`
|
||||
EggSet bool `json:",omitempty"`
|
||||
AdvertiseRoutesSet bool `json:",omitempty"`
|
||||
AdvertiseServicesSet bool `json:",omitempty"`
|
||||
NoSNATSet bool `json:",omitempty"`
|
||||
NoStatefulFilteringSet bool `json:",omitempty"`
|
||||
NetfilterModeSet bool `json:",omitempty"`
|
||||
@@ -527,6 +534,9 @@ func (p *Prefs) pretty(goos string) string {
|
||||
if len(p.AdvertiseTags) > 0 {
|
||||
fmt.Fprintf(&sb, "tags=%s ", strings.Join(p.AdvertiseTags, ","))
|
||||
}
|
||||
if len(p.AdvertiseServices) > 0 {
|
||||
fmt.Fprintf(&sb, "services=%s ", strings.Join(p.AdvertiseServices, ","))
|
||||
}
|
||||
if goos == "linux" {
|
||||
fmt.Fprintf(&sb, "nf=%v ", p.NetfilterMode)
|
||||
}
|
||||
@@ -598,6 +608,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
|
||||
p.ForceDaemon == p2.ForceDaemon &&
|
||||
compareIPNets(p.AdvertiseRoutes, p2.AdvertiseRoutes) &&
|
||||
compareStrings(p.AdvertiseTags, p2.AdvertiseTags) &&
|
||||
compareStrings(p.AdvertiseServices, p2.AdvertiseServices) &&
|
||||
p.Persist.Equals(p2.Persist) &&
|
||||
p.ProfileName == p2.ProfileName &&
|
||||
p.AutoUpdate.Equals(p2.AutoUpdate) &&
|
||||
|
||||
@@ -54,6 +54,7 @@ func TestPrefsEqual(t *testing.T) {
|
||||
"ForceDaemon",
|
||||
"Egg",
|
||||
"AdvertiseRoutes",
|
||||
"AdvertiseServices",
|
||||
"NoSNAT",
|
||||
"NoStatefulFiltering",
|
||||
"NetfilterMode",
|
||||
@@ -330,6 +331,16 @@ func TestPrefsEqual(t *testing.T) {
|
||||
&Prefs{NetfilterKind: ""},
|
||||
false,
|
||||
},
|
||||
{
|
||||
&Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},
|
||||
&Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},
|
||||
true,
|
||||
},
|
||||
{
|
||||
&Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},
|
||||
&Prefs{AdvertiseServices: []string{"svc:tux", "svc:amelie"}},
|
||||
false,
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
got := tt.a.Equals(tt.b)
|
||||
|
||||
@@ -13,19 +13,27 @@ import (
|
||||
"time"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/kube/kubeapi"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// TODO(irbekrm): should we bump this? should we have retries? See tailscale/tailscale#13024
|
||||
const timeout = 5 * time.Second
|
||||
|
||||
// Store is an ipn.StateStore that uses a Kubernetes Secret for persistence.
|
||||
type Store struct {
|
||||
client kubeclient.Client
|
||||
canPatch bool
|
||||
secretName string
|
||||
|
||||
// memory holds the latest tailscale state. Writes write state to a kube Secret and memory, Reads read from
|
||||
// memory.
|
||||
memory mem.Store
|
||||
}
|
||||
|
||||
// New returns a new Store that persists to the named secret.
|
||||
// New returns a new Store that persists to the named Secret.
|
||||
func New(_ logger.Logf, secretName string) (*Store, error) {
|
||||
c, err := kubeclient.New()
|
||||
if err != nil {
|
||||
@@ -39,11 +47,16 @@ func New(_ logger.Logf, secretName string) (*Store, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{
|
||||
s := &Store{
|
||||
client: c,
|
||||
canPatch: canPatch,
|
||||
secretName: secretName,
|
||||
}, nil
|
||||
}
|
||||
// Load latest state from kube Secret if it already exists.
|
||||
if err := s.loadState(); err != nil && err != ipn.ErrStateNotExist {
|
||||
return nil, fmt.Errorf("error loading state from kube Secret: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetDialer(d func(ctx context.Context, network, address string) (net.Conn, error)) {
|
||||
@@ -54,37 +67,17 @@ func (s *Store) String() string { return "kube.Store" }
|
||||
|
||||
// ReadState implements the StateStore interface.
|
||||
func (s *Store) ReadState(id ipn.StateKey) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
secret, err := s.client.GetSecret(ctx, s.secretName)
|
||||
if err != nil {
|
||||
if st, ok := err.(*kubeapi.Status); ok && st.Code == 404 {
|
||||
return nil, ipn.ErrStateNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
b, ok := secret.Data[sanitizeKey(id)]
|
||||
if !ok {
|
||||
return nil, ipn.ErrStateNotExist
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func sanitizeKey(k ipn.StateKey) string {
|
||||
// The only valid characters in a Kubernetes secret key are alphanumeric, -,
|
||||
// _, and .
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, string(k))
|
||||
return s.memory.ReadState(ipn.StateKey(sanitizeKey(id)))
|
||||
}
|
||||
|
||||
// WriteState implements the StateStore interface.
|
||||
func (s *Store) WriteState(id ipn.StateKey, bs []byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
|
||||
defer func() {
|
||||
if err == nil {
|
||||
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
secret, err := s.client.GetSecret(ctx, s.secretName)
|
||||
@@ -137,3 +130,29 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) loadState() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
secret, err := s.client.GetSecret(ctx, s.secretName)
|
||||
if err != nil {
|
||||
if st, ok := err.(*kubeapi.Status); ok && st.Code == 404 {
|
||||
return ipn.ErrStateNotExist
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.memory.LoadFromMap(secret.Data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeKey(k ipn.StateKey) string {
|
||||
// The only valid characters in a Kubernetes secret key are alphanumeric, -,
|
||||
// _, and .
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, string(k))
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
// New returns a new Store.
|
||||
@@ -28,6 +30,7 @@ type Store struct {
|
||||
func (s *Store) String() string { return "mem.Store" }
|
||||
|
||||
// ReadState implements the StateStore interface.
|
||||
// It returns ipn.ErrStateNotExist if the state does not exist.
|
||||
func (s *Store) ReadState(id ipn.StateKey) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -39,6 +42,7 @@ func (s *Store) ReadState(id ipn.StateKey) ([]byte, error) {
|
||||
}
|
||||
|
||||
// WriteState implements the StateStore interface.
|
||||
// It never returns an error.
|
||||
func (s *Store) WriteState(id ipn.StateKey, bs []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -49,6 +53,19 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFromMap loads the in-memory cache from the provided map.
|
||||
// Any existing content is cleared, and the provided map is
|
||||
// copied into the cache.
|
||||
func (s *Store) LoadFromMap(m map[string][]byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
xmaps.Clear(s.cache)
|
||||
for k, v := range m {
|
||||
mak.Set(&s.cache, ipn.StateKey(k), v)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// LoadFromJSON attempts to unmarshal json content into the
|
||||
// in-memory cache.
|
||||
func (s *Store) LoadFromJSON(data []byte) error {
|
||||
|
||||
@@ -381,6 +381,7 @@ _Appears in:_
|
||||
| `nodeName` _string_ | Proxy Pod's node name.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | |
|
||||
| `nodeSelector` _object (keys:string, values:string)_ | Proxy Pod's node selector.<br />By default Tailscale Kubernetes operator does not apply any node<br />selector.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | |
|
||||
| `tolerations` _[Toleration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#toleration-v1-core) array_ | Proxy Pod's tolerations.<br />By default Tailscale Kubernetes operator does not apply any<br />tolerations.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | |
|
||||
| `topologySpreadConstraints` _[TopologySpreadConstraint](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#topologyspreadconstraint-v1-core) array_ | Proxy Pod's topology spread constraints.<br />By default Tailscale Kubernetes operator does not apply any topology spread constraints.<br />https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ | | |
|
||||
|
||||
|
||||
#### ProxyClass
|
||||
|
||||
@@ -154,7 +154,11 @@ type Pod struct {
|
||||
// https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling
|
||||
// +optional
|
||||
Tolerations []corev1.Toleration `json:"tolerations,omitempty"`
|
||||
// Proxy Pod's topology spread constraints.
|
||||
// By default Tailscale Kubernetes operator does not apply any topology spread constraints.
|
||||
// https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/
|
||||
// +optional
|
||||
TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"`
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
|
||||
@@ -392,6 +392,13 @@ func (in *Pod) DeepCopyInto(out *Pod) {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
if in.TopologySpreadConstraints != nil {
|
||||
in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints
|
||||
*out = make([]corev1.TopologySpreadConstraint, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Pod.
|
||||
|
||||
@@ -36,7 +36,6 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
|
||||
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
|
||||
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/5e242ec57806/LICENSE))
|
||||
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE))
|
||||
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.2.0/LICENSE))
|
||||
- [github.com/illarion/gonotify/v2](https://pkg.go.dev/github.com/illarion/gonotify/v2) ([MIT](https://github.com/illarion/gonotify/blob/v2.0.3/LICENSE))
|
||||
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/8c70d406f6d2/LICENSE))
|
||||
@@ -57,7 +56,6 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/safchain/ethtool](https://pkg.go.dev/github.com/safchain/ethtool) ([Apache-2.0](https://github.com/safchain/ethtool/blob/v0.3.0/LICENSE))
|
||||
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/3fde5e568aa4/LICENSE))
|
||||
- [github.com/tailscale/goupnp](https://pkg.go.dev/github.com/tailscale/goupnp) ([BSD-2-Clause](https://github.com/tailscale/goupnp/blob/c64d0f06ea05/LICENSE))
|
||||
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
|
||||
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/4d49adab4de7/LICENSE))
|
||||
- [github.com/tailscale/peercred](https://pkg.go.dev/github.com/tailscale/peercred) ([BSD-3-Clause](https://github.com/tailscale/peercred/blob/b535050b2aa4/LICENSE))
|
||||
- [github.com/tailscale/tailscale-android/libtailscale](https://pkg.go.dev/github.com/tailscale/tailscale-android/libtailscale) ([BSD-3-Clause](https://github.com/tailscale/tailscale-android/blob/HEAD/LICENSE))
|
||||
|
||||
@@ -63,7 +63,6 @@ See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/safchain/ethtool](https://pkg.go.dev/github.com/safchain/ethtool) ([Apache-2.0](https://github.com/safchain/ethtool/blob/v0.3.0/LICENSE))
|
||||
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/3fde5e568aa4/LICENSE))
|
||||
- [github.com/tailscale/goupnp](https://pkg.go.dev/github.com/tailscale/goupnp) ([BSD-2-Clause](https://github.com/tailscale/goupnp/blob/c64d0f06ea05/LICENSE))
|
||||
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
|
||||
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/4d49adab4de7/LICENSE))
|
||||
- [github.com/tailscale/peercred](https://pkg.go.dev/github.com/tailscale/peercred) ([BSD-3-Clause](https://github.com/tailscale/peercred/blob/b535050b2aa4/LICENSE))
|
||||
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/799c1978fafc/LICENSE))
|
||||
@@ -74,13 +73,13 @@ See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
|
||||
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
|
||||
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.25.0:LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.28.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fe59bbe5:LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.27.0:LICENSE))
|
||||
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.7.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.22.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.22.0:LICENSE))
|
||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.16.0:LICENSE))
|
||||
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.8.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.26.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.25.0:LICENSE))
|
||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.19.0:LICENSE))
|
||||
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE))
|
||||
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/64c016c92987/LICENSE))
|
||||
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))
|
||||
|
||||
@@ -80,7 +80,6 @@ Some packages may only be included on certain architectures or operating systems
|
||||
- [github.com/tailscale/certstore](https://pkg.go.dev/github.com/tailscale/certstore) ([MIT](https://github.com/tailscale/certstore/blob/d3fa0460f47e/LICENSE.md))
|
||||
- [github.com/tailscale/go-winio](https://pkg.go.dev/github.com/tailscale/go-winio) ([MIT](https://github.com/tailscale/go-winio/blob/c4f33415bf55/LICENSE))
|
||||
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/3fde5e568aa4/LICENSE))
|
||||
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
|
||||
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/4d49adab4de7/LICENSE))
|
||||
- [github.com/tailscale/peercred](https://pkg.go.dev/github.com/tailscale/peercred) ([BSD-3-Clause](https://github.com/tailscale/peercred/blob/b535050b2aa4/LICENSE))
|
||||
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/5db17b287bf1/LICENSE))
|
||||
|
||||
@@ -65,15 +65,15 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
|
||||
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
|
||||
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.25.0:LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.28.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fe59bbe5:LICENSE))
|
||||
- [golang.org/x/image/bmp](https://pkg.go.dev/golang.org/x/image/bmp) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.18.0:LICENSE))
|
||||
- [golang.org/x/mod](https://pkg.go.dev/golang.org/x/mod) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.19.0:LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.27.0:LICENSE))
|
||||
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.7.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.22.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.22.0:LICENSE))
|
||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.16.0:LICENSE))
|
||||
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.8.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.26.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.25.0:LICENSE))
|
||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.19.0:LICENSE))
|
||||
- [golang.zx2c4.com/wintun](https://pkg.go.dev/golang.zx2c4.com/wintun) ([MIT](https://git.zx2c4.com/wintun-go/tree/LICENSE?id=0fa3db229ce2))
|
||||
- [golang.zx2c4.com/wireguard/windows/tunnel/winipcfg](https://pkg.go.dev/golang.zx2c4.com/wireguard/windows/tunnel/winipcfg) ([MIT](https://git.zx2c4.com/wireguard-windows/tree/COPYING?h=v0.5.3))
|
||||
- [gopkg.in/Knetic/govaluate.v3](https://pkg.go.dev/gopkg.in/Knetic/govaluate.v3) ([MIT](https://github.com/Knetic/govaluate/blob/v3.0.0/LICENSE))
|
||||
|
||||
@@ -230,6 +230,9 @@ func LogsDir(logf logger.Logf) string {
|
||||
logf("logpolicy: using $STATE_DIRECTORY, %q", systemdStateDir)
|
||||
return systemdStateDir
|
||||
}
|
||||
case "js":
|
||||
logf("logpolicy: no logs directory in the browser")
|
||||
return ""
|
||||
}
|
||||
|
||||
// Default to e.g. /var/lib/tailscale or /var/db/tailscale on Unix.
|
||||
|
||||
@@ -131,23 +131,23 @@ func (s *Statistics) updateVirtual(b []byte, receive bool) {
|
||||
s.virtual[conn] = cnts
|
||||
}
|
||||
|
||||
// UpdateTxPhysical updates the counters for a transmitted wireguard packet
|
||||
// UpdateTxPhysical updates the counters for zero or more transmitted wireguard packets.
|
||||
// The src is always a Tailscale IP address, representing some remote peer.
|
||||
// The dst is a remote IP address and port that corresponds
|
||||
// with some physical peer backing the Tailscale IP address.
|
||||
func (s *Statistics) UpdateTxPhysical(src netip.Addr, dst netip.AddrPort, n int) {
|
||||
s.updatePhysical(src, dst, n, false)
|
||||
func (s *Statistics) UpdateTxPhysical(src netip.Addr, dst netip.AddrPort, packets, bytes int) {
|
||||
s.updatePhysical(src, dst, packets, bytes, false)
|
||||
}
|
||||
|
||||
// UpdateRxPhysical updates the counters for a received wireguard packet.
|
||||
// UpdateRxPhysical updates the counters for zero or more received wireguard packets.
|
||||
// The src is always a Tailscale IP address, representing some remote peer.
|
||||
// The dst is a remote IP address and port that corresponds
|
||||
// with some physical peer backing the Tailscale IP address.
|
||||
func (s *Statistics) UpdateRxPhysical(src netip.Addr, dst netip.AddrPort, n int) {
|
||||
s.updatePhysical(src, dst, n, true)
|
||||
func (s *Statistics) UpdateRxPhysical(src netip.Addr, dst netip.AddrPort, packets, bytes int) {
|
||||
s.updatePhysical(src, dst, packets, bytes, true)
|
||||
}
|
||||
|
||||
func (s *Statistics) updatePhysical(src netip.Addr, dst netip.AddrPort, n int, receive bool) {
|
||||
func (s *Statistics) updatePhysical(src netip.Addr, dst netip.AddrPort, packets, bytes int, receive bool) {
|
||||
conn := netlogtype.Connection{Src: netip.AddrPortFrom(src, 0), Dst: dst}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -157,11 +157,11 @@ func (s *Statistics) updatePhysical(src netip.Addr, dst netip.AddrPort, n int, r
|
||||
return
|
||||
}
|
||||
if receive {
|
||||
cnts.RxPackets++
|
||||
cnts.RxBytes += uint64(n)
|
||||
cnts.RxPackets += uint64(packets)
|
||||
cnts.RxBytes += uint64(bytes)
|
||||
} else {
|
||||
cnts.TxPackets++
|
||||
cnts.TxBytes += uint64(n)
|
||||
cnts.TxPackets += uint64(packets)
|
||||
cnts.TxBytes += uint64(bytes)
|
||||
}
|
||||
s.physical[conn] = cnts
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -62,7 +63,9 @@ type Manager struct {
|
||||
mu sync.Mutex // guards following
|
||||
// config is the last configuration we successfully compiled or nil if there
|
||||
// was any failure applying the last configuration.
|
||||
config *Config
|
||||
config *Config
|
||||
forceAAAA bool // whether client wants MagicDNS AAAA even if unsure of host's IPv6 status
|
||||
forceFallbackResolvers []*dnstype.Resolver
|
||||
}
|
||||
|
||||
// NewManagers created a new manager from the given config.
|
||||
@@ -127,6 +130,28 @@ func (m *Manager) GetBaseConfig() (OSConfig, error) {
|
||||
return m.os.GetBaseConfig()
|
||||
}
|
||||
|
||||
func (m *Manager) GetForceAAAA() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.forceAAAA
|
||||
}
|
||||
|
||||
func (m *Manager) SetForceAAAA(v bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.forceAAAA = v
|
||||
}
|
||||
|
||||
// SetForceFallbackResolvers sets the resolvers to use to override
|
||||
// the fallback resolvers if the control plane doesn't send any.
|
||||
//
|
||||
// It takes ownership of the provided slice.
|
||||
func (m *Manager) SetForceFallbackResolvers(resolvers []*dnstype.Resolver) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.forceFallbackResolvers = resolvers
|
||||
}
|
||||
|
||||
// setLocked sets the DNS configuration.
|
||||
//
|
||||
// m.mu must be held.
|
||||
@@ -145,6 +170,10 @@ func (m *Manager) setLocked(cfg Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := rcfg.Routes["."]; !ok && len(m.forceFallbackResolvers) > 0 {
|
||||
rcfg.Routes["."] = m.forceFallbackResolvers
|
||||
}
|
||||
|
||||
m.logf("Resolvercfg: %v", logger.ArgWriter(func(w *bufio.Writer) {
|
||||
rcfg.WriteToBufioWriter(w)
|
||||
}))
|
||||
@@ -156,11 +185,11 @@ func (m *Manager) setLocked(cfg Config) error {
|
||||
return err
|
||||
}
|
||||
if err := m.os.SetDNS(ocfg); err != nil {
|
||||
m.health.SetDNSOSHealth(err)
|
||||
m.health.SetUnhealthy(osConfigurationSetWarnable, health.Args{health.ArgError: err.Error()})
|
||||
return err
|
||||
}
|
||||
|
||||
m.health.SetDNSOSHealth(nil)
|
||||
m.health.SetHealthy(osConfigurationSetWarnable)
|
||||
m.config = &cfg
|
||||
|
||||
return nil
|
||||
@@ -217,6 +246,26 @@ func compileHostEntries(cfg Config) (hosts []*HostEntry) {
|
||||
return hosts
|
||||
}
|
||||
|
||||
var osConfigurationReadWarnable = health.Register(&health.Warnable{
|
||||
Code: "dns-read-os-config-failed",
|
||||
Title: "Failed to read system DNS configuration",
|
||||
Text: func(args health.Args) string {
|
||||
return fmt.Sprintf("Tailscale failed to fetch the DNS configuration of your device: %v", args[health.ArgError])
|
||||
},
|
||||
Severity: health.SeverityLow,
|
||||
DependsOn: []*health.Warnable{health.NetworkStatusWarnable},
|
||||
})
|
||||
|
||||
var osConfigurationSetWarnable = health.Register(&health.Warnable{
|
||||
Code: "dns-set-os-config-failed",
|
||||
Title: "Failed to set system DNS configuration",
|
||||
Text: func(args health.Args) string {
|
||||
return fmt.Sprintf("Tailscale failed to set the DNS configuration of your device: %v", args[health.ArgError])
|
||||
},
|
||||
Severity: health.SeverityMedium,
|
||||
DependsOn: []*health.Warnable{health.NetworkStatusWarnable},
|
||||
})
|
||||
|
||||
// compileConfig converts cfg into a quad-100 resolver configuration
|
||||
// and an OS-level configuration.
|
||||
func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig, err error) {
|
||||
@@ -320,9 +369,10 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
|
||||
// This is currently (2022-10-13) expected on certain iOS and macOS
|
||||
// builds.
|
||||
} else {
|
||||
m.health.SetDNSOSHealth(err)
|
||||
m.health.SetUnhealthy(osConfigurationReadWarnable, health.Args{health.ArgError: err.Error()})
|
||||
return resolver.Config{}, OSConfig{}, err
|
||||
}
|
||||
m.health.SetHealthy(osConfigurationReadWarnable)
|
||||
}
|
||||
|
||||
if baseCfg == nil {
|
||||
|
||||
@@ -57,6 +57,7 @@ func (m *resolvdManager) SetDNS(config OSConfig) error {
|
||||
|
||||
if len(newSearch) > 1 {
|
||||
newResolvConf = append(newResolvConf, []byte(strings.Join(newSearch, " "))...)
|
||||
newResolvConf = append(newResolvConf, '\n')
|
||||
}
|
||||
|
||||
err = m.fs.WriteFile(resolvConf, newResolvConf, 0644)
|
||||
@@ -123,6 +124,6 @@ func (m resolvdManager) readResolvConf() (config OSConfig, err error) {
|
||||
}
|
||||
|
||||
func removeSearchLines(orig []byte) []byte {
|
||||
re := regexp.MustCompile(`(?m)^search\s+.+$`)
|
||||
re := regexp.MustCompile(`(?ms)^search\s+.+$`)
|
||||
return re.ReplaceAll(orig, []byte(""))
|
||||
}
|
||||
|
||||
@@ -163,9 +163,9 @@ func (m *resolvedManager) run(ctx context.Context) {
|
||||
}
|
||||
conn.Signal(signals)
|
||||
|
||||
// Reset backoff and SetNSOSHealth after successful on reconnect.
|
||||
// Reset backoff and set osConfigurationSetWarnable to healthy after a successful reconnect.
|
||||
bo.BackOff(ctx, nil)
|
||||
m.health.SetDNSOSHealth(nil)
|
||||
m.health.SetHealthy(osConfigurationSetWarnable)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -243,9 +243,12 @@ func (m *resolvedManager) run(ctx context.Context) {
|
||||
// Set health while holding the lock, because this will
|
||||
// graciously serialize the resync's health outcome with a
|
||||
// concurrent SetDNS call.
|
||||
m.health.SetDNSOSHealth(err)
|
||||
|
||||
if err != nil {
|
||||
m.logf("failed to configure systemd-resolved: %v", err)
|
||||
m.health.SetUnhealthy(osConfigurationSetWarnable, health.Args{health.ArgError: err.Error()})
|
||||
} else {
|
||||
m.health.SetHealthy(osConfigurationSetWarnable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +487,10 @@ func (f *forwarder) sendDoH(ctx context.Context, urlBase string, c *http.Client,
|
||||
defer hres.Body.Close()
|
||||
if hres.StatusCode != 200 {
|
||||
metricDNSFwdDoHErrorStatus.Add(1)
|
||||
if hres.StatusCode/100 == 5 {
|
||||
// Translate 5xx HTTP server errors into SERVFAIL DNS responses.
|
||||
return nil, fmt.Errorf("%w: %s", errServerFailure, hres.Status)
|
||||
}
|
||||
return nil, errors.New(hres.Status)
|
||||
}
|
||||
if ct := hres.Header.Get("Content-Type"); ct != dohType {
|
||||
@@ -916,10 +920,7 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
|
||||
metricDNSFwdDropBonjour.Add(1)
|
||||
res, err := nxDomainResponse(query)
|
||||
if err != nil {
|
||||
f.logf("error parsing bonjour query: %v", err)
|
||||
// Returning an error will cause an internal retry, there is
|
||||
// nothing we can do if parsing failed. Just drop the packet.
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -951,10 +952,7 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
|
||||
|
||||
res, err := servfailResponse(query)
|
||||
if err != nil {
|
||||
f.logf("building servfail response: %v", err)
|
||||
// Returning an error will cause an internal retry, there is
|
||||
// nothing we can do if parsing failed. Just drop the packet.
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -1053,6 +1051,7 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
|
||||
if verboseDNSForward() {
|
||||
f.logf("forwarder response(%d, %v, %d) = %d, %v", fq.txid, typ, len(domain), len(res.bs), firstErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -28,6 +27,7 @@ import (
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/dnstype"
|
||||
)
|
||||
|
||||
@@ -277,6 +277,8 @@ func runDNSServer(tb testing.TB, opts *testDNSServerOptions, response []byte, on
|
||||
tb.Fatal("cannot skip both UDP and TCP servers")
|
||||
}
|
||||
|
||||
logf := tstest.WhileTestRunningLogger(tb)
|
||||
|
||||
tcpResponse := make([]byte, len(response)+2)
|
||||
binary.BigEndian.PutUint16(tcpResponse, uint16(len(response)))
|
||||
copy(tcpResponse[2:], response)
|
||||
@@ -330,13 +332,13 @@ func runDNSServer(tb testing.TB, opts *testDNSServerOptions, response []byte, on
|
||||
// Read the length header, then the buffer
|
||||
var length uint16
|
||||
if err := binary.Read(conn, binary.BigEndian, &length); err != nil {
|
||||
tb.Logf("error reading length header: %v", err)
|
||||
logf("error reading length header: %v", err)
|
||||
return
|
||||
}
|
||||
req := make([]byte, length)
|
||||
n, err := io.ReadFull(conn, req)
|
||||
if err != nil {
|
||||
tb.Logf("error reading query: %v", err)
|
||||
logf("error reading query: %v", err)
|
||||
return
|
||||
}
|
||||
req = req[:n]
|
||||
@@ -344,7 +346,7 @@ func runDNSServer(tb testing.TB, opts *testDNSServerOptions, response []byte, on
|
||||
|
||||
// Write response
|
||||
if _, err := conn.Write(tcpResponse); err != nil {
|
||||
tb.Logf("error writing response: %v", err)
|
||||
logf("error writing response: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -368,7 +370,7 @@ func runDNSServer(tb testing.TB, opts *testDNSServerOptions, response []byte, on
|
||||
handleUDP := func(addr netip.AddrPort, req []byte) {
|
||||
onRequest(false, req)
|
||||
if _, err := udpLn.WriteToUDPAddrPort(response, addr); err != nil {
|
||||
tb.Logf("error writing response: %v", err)
|
||||
logf("error writing response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +393,7 @@ func runDNSServer(tb testing.TB, opts *testDNSServerOptions, response []byte, on
|
||||
tb.Cleanup(func() {
|
||||
tcpLn.Close()
|
||||
udpLn.Close()
|
||||
tb.Logf("waiting for listeners to finish...")
|
||||
logf("waiting for listeners to finish...")
|
||||
wg.Wait()
|
||||
})
|
||||
return
|
||||
@@ -450,8 +452,9 @@ func makeLargeResponse(tb testing.TB, domain string) (request, response []byte)
|
||||
return
|
||||
}
|
||||
|
||||
func runTestQuery(tb testing.TB, port uint16, request []byte, modify func(*forwarder)) ([]byte, error) {
|
||||
netMon, err := netmon.New(tb.Logf)
|
||||
func runTestQuery(tb testing.TB, request []byte, modify func(*forwarder), ports ...uint16) ([]byte, error) {
|
||||
logf := tstest.WhileTestRunningLogger(tb)
|
||||
netMon, err := netmon.New(logf)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
@@ -459,13 +462,14 @@ func runTestQuery(tb testing.TB, port uint16, request []byte, modify func(*forwa
|
||||
var dialer tsdial.Dialer
|
||||
dialer.SetNetMon(netMon)
|
||||
|
||||
fwd := newForwarder(tb.Logf, netMon, nil, &dialer, new(health.Tracker), nil)
|
||||
fwd := newForwarder(logf, netMon, nil, &dialer, new(health.Tracker), nil)
|
||||
if modify != nil {
|
||||
modify(fwd)
|
||||
}
|
||||
|
||||
rr := resolverAndDelay{
|
||||
name: &dnstype.Resolver{Addr: fmt.Sprintf("127.0.0.1:%d", port)},
|
||||
resolvers := make([]resolverAndDelay, len(ports))
|
||||
for i, port := range ports {
|
||||
resolvers[i].name = &dnstype.Resolver{Addr: fmt.Sprintf("127.0.0.1:%d", port)}
|
||||
}
|
||||
|
||||
rpkt := packet{
|
||||
@@ -477,7 +481,7 @@ func runTestQuery(tb testing.TB, port uint16, request []byte, modify func(*forwa
|
||||
rchan := make(chan packet, 1)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
tb.Cleanup(cancel)
|
||||
err = fwd.forwardWithDestChan(ctx, rpkt, rchan, rr)
|
||||
err = fwd.forwardWithDestChan(ctx, rpkt, rchan, resolvers...)
|
||||
select {
|
||||
case res := <-rchan:
|
||||
return res.bs, err
|
||||
@@ -486,8 +490,62 @@ func runTestQuery(tb testing.TB, port uint16, request []byte, modify func(*forwa
|
||||
}
|
||||
}
|
||||
|
||||
func mustRunTestQuery(tb testing.TB, port uint16, request []byte, modify func(*forwarder)) []byte {
|
||||
resp, err := runTestQuery(tb, port, request, modify)
|
||||
// makeTestRequest returns a new TypeA request for the given domain.
|
||||
func makeTestRequest(tb testing.TB, domain string) []byte {
|
||||
tb.Helper()
|
||||
name := dns.MustNewName(domain)
|
||||
builder := dns.NewBuilder(nil, dns.Header{})
|
||||
builder.StartQuestions()
|
||||
builder.Question(dns.Question{
|
||||
Name: name,
|
||||
Type: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
})
|
||||
request, err := builder.Finish()
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// makeTestResponse returns a new Type A response for the given domain,
|
||||
// with the specified status code and zero or more addresses.
|
||||
func makeTestResponse(tb testing.TB, domain string, code dns.RCode, addrs ...netip.Addr) []byte {
|
||||
tb.Helper()
|
||||
name := dns.MustNewName(domain)
|
||||
builder := dns.NewBuilder(nil, dns.Header{
|
||||
Response: true,
|
||||
Authoritative: true,
|
||||
RCode: code,
|
||||
})
|
||||
builder.StartQuestions()
|
||||
q := dns.Question{
|
||||
Name: name,
|
||||
Type: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
}
|
||||
builder.Question(q)
|
||||
if len(addrs) > 0 {
|
||||
builder.StartAnswers()
|
||||
for _, addr := range addrs {
|
||||
builder.AResource(dns.ResourceHeader{
|
||||
Name: q.Name,
|
||||
Class: q.Class,
|
||||
TTL: 120,
|
||||
}, dns.AResource{
|
||||
A: addr.As4(),
|
||||
})
|
||||
}
|
||||
}
|
||||
response, err := builder.Finish()
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func mustRunTestQuery(tb testing.TB, request []byte, modify func(*forwarder), ports ...uint16) []byte {
|
||||
resp, err := runTestQuery(tb, request, modify, ports...)
|
||||
if err != nil {
|
||||
tb.Fatalf("error making request: %v", err)
|
||||
}
|
||||
@@ -516,7 +574,7 @@ func TestForwarderTCPFallback(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
resp := mustRunTestQuery(t, port, request, nil)
|
||||
resp := mustRunTestQuery(t, request, nil, port)
|
||||
if !bytes.Equal(resp, largeResponse) {
|
||||
t.Errorf("invalid response\ngot: %+v\nwant: %+v", resp, largeResponse)
|
||||
}
|
||||
@@ -554,7 +612,7 @@ func TestForwarderTCPFallbackTimeout(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
resp := mustRunTestQuery(t, port, request, nil)
|
||||
resp := mustRunTestQuery(t, request, nil, port)
|
||||
if !bytes.Equal(resp, largeResponse) {
|
||||
t.Errorf("invalid response\ngot: %+v\nwant: %+v", resp, largeResponse)
|
||||
}
|
||||
@@ -585,11 +643,11 @@ func TestForwarderTCPFallbackDisabled(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
resp := mustRunTestQuery(t, port, request, func(fwd *forwarder) {
|
||||
resp := mustRunTestQuery(t, request, func(fwd *forwarder) {
|
||||
// Disable retries for this test.
|
||||
fwd.controlKnobs = &controlknobs.Knobs{}
|
||||
fwd.controlKnobs.DisableDNSForwarderTCPRetries.Store(true)
|
||||
})
|
||||
}, port)
|
||||
|
||||
wantResp := append([]byte(nil), largeResponse[:maxResponseBytes]...)
|
||||
|
||||
@@ -613,41 +671,10 @@ func TestForwarderTCPFallbackError(t *testing.T) {
|
||||
const domain = "error-response.tailscale.com."
|
||||
|
||||
// Our response is a SERVFAIL
|
||||
response := func() []byte {
|
||||
name := dns.MustNewName(domain)
|
||||
|
||||
builder := dns.NewBuilder(nil, dns.Header{
|
||||
Response: true,
|
||||
RCode: dns.RCodeServerFailure,
|
||||
})
|
||||
builder.StartQuestions()
|
||||
builder.Question(dns.Question{
|
||||
Name: name,
|
||||
Type: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
})
|
||||
response, err := builder.Finish()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}()
|
||||
response := makeTestResponse(t, domain, dns.RCodeServerFailure)
|
||||
|
||||
// Our request is a single A query for the domain in the answer, above.
|
||||
request := func() []byte {
|
||||
builder := dns.NewBuilder(nil, dns.Header{})
|
||||
builder.StartQuestions()
|
||||
builder.Question(dns.Question{
|
||||
Name: dns.MustNewName(domain),
|
||||
Type: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
})
|
||||
request, err := builder.Finish()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return request
|
||||
}()
|
||||
request := makeTestRequest(t, domain)
|
||||
|
||||
var sawRequest atomic.Bool
|
||||
port := runDNSServer(t, nil, response, func(isTCP bool, gotRequest []byte) {
|
||||
@@ -657,14 +684,141 @@ func TestForwarderTCPFallbackError(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
_, err := runTestQuery(t, port, request, nil)
|
||||
resp, err := runTestQuery(t, request, nil, port)
|
||||
if !sawRequest.Load() {
|
||||
t.Error("did not see DNS request")
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("wanted error, got nil")
|
||||
} else if !errors.Is(err, errServerFailure) {
|
||||
t.Errorf("wanted errServerFailure, got: %v", err)
|
||||
if err != nil {
|
||||
t.Fatalf("wanted nil, got %v", err)
|
||||
}
|
||||
var parser dns.Parser
|
||||
respHeader, err := parser.Start(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("parser.Start() failed: %v", err)
|
||||
}
|
||||
if got, want := respHeader.RCode, dns.RCodeServerFailure; got != want {
|
||||
t.Errorf("wanted %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Test to ensure that if we have more than one resolver, and at least one of them
|
||||
// returns a successful response, we propagate it.
|
||||
func TestForwarderWithManyResolvers(t *testing.T) {
|
||||
enableDebug(t)
|
||||
|
||||
const domain = "example.com."
|
||||
request := makeTestRequest(t, domain)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
responses [][]byte // upstream responses
|
||||
wantResponses [][]byte // we should receive one of these from the forwarder
|
||||
}{
|
||||
{
|
||||
name: "Success",
|
||||
responses: [][]byte{ // All upstream servers returned successful, but different, response.
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")),
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.2")),
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.3")),
|
||||
},
|
||||
wantResponses: [][]byte{ // We may forward whichever response is received first.
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")),
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.2")),
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.3")),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ServFail",
|
||||
responses: [][]byte{ // All upstream servers returned a SERVFAIL.
|
||||
makeTestResponse(t, domain, dns.RCodeServerFailure),
|
||||
makeTestResponse(t, domain, dns.RCodeServerFailure),
|
||||
makeTestResponse(t, domain, dns.RCodeServerFailure),
|
||||
},
|
||||
wantResponses: [][]byte{
|
||||
makeTestResponse(t, domain, dns.RCodeServerFailure),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ServFail+Success",
|
||||
responses: [][]byte{ // All upstream servers fail except for one.
|
||||
makeTestResponse(t, domain, dns.RCodeServerFailure),
|
||||
makeTestResponse(t, domain, dns.RCodeServerFailure),
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")),
|
||||
makeTestResponse(t, domain, dns.RCodeServerFailure),
|
||||
},
|
||||
wantResponses: [][]byte{ // We should forward the successful response.
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NXDomain",
|
||||
responses: [][]byte{ // All upstream servers returned NXDOMAIN.
|
||||
makeTestResponse(t, domain, dns.RCodeNameError),
|
||||
makeTestResponse(t, domain, dns.RCodeNameError),
|
||||
makeTestResponse(t, domain, dns.RCodeNameError),
|
||||
},
|
||||
wantResponses: [][]byte{
|
||||
makeTestResponse(t, domain, dns.RCodeNameError),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NXDomain+Success",
|
||||
responses: [][]byte{ // All upstream servers returned NXDOMAIN except for one.
|
||||
makeTestResponse(t, domain, dns.RCodeNameError),
|
||||
makeTestResponse(t, domain, dns.RCodeNameError),
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")),
|
||||
},
|
||||
wantResponses: [][]byte{ // However, only SERVFAIL are considered to be errors. Therefore, we may forward any response.
|
||||
makeTestResponse(t, domain, dns.RCodeNameError),
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Refused",
|
||||
responses: [][]byte{ // All upstream servers return different failures.
|
||||
makeTestResponse(t, domain, dns.RCodeRefused),
|
||||
makeTestResponse(t, domain, dns.RCodeRefused),
|
||||
makeTestResponse(t, domain, dns.RCodeRefused),
|
||||
makeTestResponse(t, domain, dns.RCodeRefused),
|
||||
makeTestResponse(t, domain, dns.RCodeRefused),
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")),
|
||||
},
|
||||
wantResponses: [][]byte{ // Refused is not considered to be an error and can be forwarded.
|
||||
makeTestResponse(t, domain, dns.RCodeRefused),
|
||||
makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MixFail",
|
||||
responses: [][]byte{ // All upstream servers return different failures.
|
||||
makeTestResponse(t, domain, dns.RCodeServerFailure),
|
||||
makeTestResponse(t, domain, dns.RCodeNameError),
|
||||
makeTestResponse(t, domain, dns.RCodeRefused),
|
||||
},
|
||||
wantResponses: [][]byte{ // Both NXDomain and Refused can be forwarded.
|
||||
makeTestResponse(t, domain, dns.RCodeNameError),
|
||||
makeTestResponse(t, domain, dns.RCodeRefused),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ports := make([]uint16, len(tt.responses))
|
||||
for i := range tt.responses {
|
||||
ports[i] = runDNSServer(t, nil, tt.responses[i], func(isTCP bool, gotRequest []byte) {})
|
||||
}
|
||||
gotResponse, err := runTestQuery(t, request, nil, ports...)
|
||||
if err != nil {
|
||||
t.Fatalf("wanted nil, got %v", err)
|
||||
}
|
||||
responseOk := slices.ContainsFunc(tt.wantResponses, func(wantResponse []byte) bool {
|
||||
return slices.Equal(gotResponse, wantResponse)
|
||||
})
|
||||
if !responseOk {
|
||||
t.Errorf("invalid response\ngot: %+v\nwant: %+v", gotResponse, tt.wantResponses[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,7 +867,7 @@ func TestNXDOMAINIncludesQuestion(t *testing.T) {
|
||||
port := runDNSServer(t, nil, response, func(isTCP bool, gotRequest []byte) {
|
||||
})
|
||||
|
||||
res, err := runTestQuery(t, port, request, nil)
|
||||
res, err := runTestQuery(t, request, nil, port)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -321,15 +321,7 @@ func (r *Resolver) Query(ctx context.Context, bs []byte, family string, from net
|
||||
defer cancel()
|
||||
err = r.forwarder.forwardWithDestChan(ctx, packet{bs, family, from}, responses)
|
||||
if err != nil {
|
||||
select {
|
||||
// Best effort: use any error response sent by forwardWithDestChan.
|
||||
// This is present in some errors paths, such as when all upstream
|
||||
// DNS servers replied with an error.
|
||||
case resp := <-responses:
|
||||
return resp.bs, err
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return (<-responses).bs, nil
|
||||
}
|
||||
|
||||
@@ -1503,8 +1503,8 @@ func TestServfail(t *testing.T) {
|
||||
r.SetConfig(cfg)
|
||||
|
||||
pkt, err := syncRespond(r, dnspacket("test.site.", dns.TypeA, noEdns))
|
||||
if !errors.Is(err, errServerFailure) {
|
||||
t.Errorf("err = %v, want %v", err, errServerFailure)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
|
||||
wantPkt := []byte{
|
||||
|
||||
@@ -85,13 +85,14 @@ const (
|
||||
|
||||
// Report contains the result of a single netcheck.
|
||||
type Report struct {
|
||||
UDP bool // a UDP STUN round trip completed
|
||||
IPv6 bool // an IPv6 STUN round trip completed
|
||||
IPv4 bool // an IPv4 STUN round trip completed
|
||||
IPv6CanSend bool // an IPv6 packet was able to be sent
|
||||
IPv4CanSend bool // an IPv4 packet was able to be sent
|
||||
OSHasIPv6 bool // could bind a socket to ::1
|
||||
ICMPv4 bool // an ICMPv4 round trip completed
|
||||
Now time.Time // the time the report was run
|
||||
UDP bool // a UDP STUN round trip completed
|
||||
IPv6 bool // an IPv6 STUN round trip completed
|
||||
IPv4 bool // an IPv4 STUN round trip completed
|
||||
IPv6CanSend bool // an IPv6 packet was able to be sent
|
||||
IPv4CanSend bool // an IPv4 packet was able to be sent
|
||||
OSHasIPv6 bool // could bind a socket to ::1
|
||||
ICMPv4 bool // an ICMPv4 round trip completed
|
||||
|
||||
// MappingVariesByDestIP is whether STUN results depend which
|
||||
// STUN server you're talking to (on IPv4).
|
||||
@@ -391,10 +392,11 @@ type probePlan map[string][]probe
|
||||
// sortRegions returns the regions of dm first sorted
|
||||
// from fastest to slowest (based on the 'last' report),
|
||||
// end in regions that have no data.
|
||||
func sortRegions(dm *tailcfg.DERPMap, last *Report) (prev []*tailcfg.DERPRegion) {
|
||||
func sortRegions(dm *tailcfg.DERPMap, last *Report, preferredDERP int) (prev []*tailcfg.DERPRegion) {
|
||||
prev = make([]*tailcfg.DERPRegion, 0, len(dm.Regions))
|
||||
for _, reg := range dm.Regions {
|
||||
if reg.Avoid {
|
||||
// include an otherwise avoid region if it is the current preferred region
|
||||
if reg.Avoid && reg.RegionID != preferredDERP {
|
||||
continue
|
||||
}
|
||||
prev = append(prev, reg)
|
||||
@@ -419,9 +421,19 @@ func sortRegions(dm *tailcfg.DERPMap, last *Report) (prev []*tailcfg.DERPRegion)
|
||||
// a full report, all regions are scanned.)
|
||||
const numIncrementalRegions = 3
|
||||
|
||||
// makeProbePlan generates the probe plan for a DERPMap, given the most
|
||||
// recent report and whether IPv6 is configured on an interface.
|
||||
func makeProbePlan(dm *tailcfg.DERPMap, ifState *netmon.State, last *Report) (plan probePlan) {
|
||||
// makeProbePlan generates the probe plan for a DERPMap, given the most recent
|
||||
// report and the current home DERP. preferredDERP is passed independently of
|
||||
// last (report) because last is currently nil'd to indicate a desire for a full
|
||||
// netcheck.
|
||||
//
|
||||
// TODO(raggi,jwhited): refactor the callers and this function to be more clear
|
||||
// about full vs. incremental netchecks, and remove the need for the history
|
||||
// hiding. This was avoided in an incremental change due to exactly this kind of
|
||||
// distant coupling.
|
||||
// TODO(raggi): change from "preferred DERP" from a historical report to "home
|
||||
// DERP" as in what DERP is the current home connection, this would further
|
||||
// reduce flap events.
|
||||
func makeProbePlan(dm *tailcfg.DERPMap, ifState *netmon.State, last *Report, preferredDERP int) (plan probePlan) {
|
||||
if last == nil || len(last.RegionLatency) == 0 {
|
||||
return makeProbePlanInitial(dm, ifState)
|
||||
}
|
||||
@@ -432,9 +444,34 @@ func makeProbePlan(dm *tailcfg.DERPMap, ifState *netmon.State, last *Report) (pl
|
||||
had4 := len(last.RegionV4Latency) > 0
|
||||
had6 := len(last.RegionV6Latency) > 0
|
||||
hadBoth := have6if && had4 && had6
|
||||
for ri, reg := range sortRegions(dm, last) {
|
||||
if ri == numIncrementalRegions {
|
||||
break
|
||||
// #13969 ensure that the home region is always probed.
|
||||
// If a netcheck has unstable latency, such as a user with large amounts of
|
||||
// bufferbloat or a highly congested connection, there are cases where a full
|
||||
// netcheck may observe a one-off high latency to the current home DERP. Prior
|
||||
// to the forced inclusion of the home DERP, this would result in an
|
||||
// incremental netcheck following such an event to cause a home DERP move, with
|
||||
// restoration back to the home DERP on the next full netcheck ~5 minutes later
|
||||
// - which is highly disruptive when it causes shifts in geo routed subnet
|
||||
// routers. By always including the home DERP in the incremental netcheck, we
|
||||
// ensure that the home DERP is always probed, even if it observed a recenet
|
||||
// poor latency sample. This inclusion enables the latency history checks in
|
||||
// home DERP selection to still take effect.
|
||||
// planContainsHome indicates whether the home DERP has been added to the probePlan,
|
||||
// if there is no prior home, then there's no home to additionally include.
|
||||
planContainsHome := preferredDERP == 0
|
||||
for ri, reg := range sortRegions(dm, last, preferredDERP) {
|
||||
regIsHome := reg.RegionID == preferredDERP
|
||||
if ri >= numIncrementalRegions {
|
||||
// planned at least numIncrementalRegions regions and that includes the
|
||||
// last home region (or there was none), plan complete.
|
||||
if planContainsHome {
|
||||
break
|
||||
}
|
||||
// planned at least numIncrementalRegions regions, but not the home region,
|
||||
// check if this is the home region, if not, skip it.
|
||||
if !regIsHome {
|
||||
continue
|
||||
}
|
||||
}
|
||||
var p4, p6 []probe
|
||||
do4 := have4if
|
||||
@@ -445,7 +482,7 @@ func makeProbePlan(dm *tailcfg.DERPMap, ifState *netmon.State, last *Report) (pl
|
||||
tries := 1
|
||||
isFastestTwo := ri < 2
|
||||
|
||||
if isFastestTwo {
|
||||
if isFastestTwo || regIsHome {
|
||||
tries = 2
|
||||
} else if hadBoth {
|
||||
// For dual stack machines, make the 3rd & slower nodes alternate
|
||||
@@ -456,14 +493,15 @@ func makeProbePlan(dm *tailcfg.DERPMap, ifState *netmon.State, last *Report) (pl
|
||||
do4, do6 = false, true
|
||||
}
|
||||
}
|
||||
if !isFastestTwo && !had6 {
|
||||
if !regIsHome && !isFastestTwo && !had6 {
|
||||
do6 = false
|
||||
}
|
||||
|
||||
if reg.RegionID == last.PreferredDERP {
|
||||
if regIsHome {
|
||||
// But if we already had a DERP home, try extra hard to
|
||||
// make sure it's there so we don't flip flop around.
|
||||
tries = 4
|
||||
planContainsHome = true
|
||||
}
|
||||
|
||||
for try := 0; try < tries; try++ {
|
||||
@@ -788,9 +826,10 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
|
||||
c.curState = rs
|
||||
last := c.last
|
||||
|
||||
// Even if we're doing a non-incremental update, we may want to try our
|
||||
// preferred DERP region for captive portal detection. Save that, if we
|
||||
// have it.
|
||||
// Extract preferredDERP from the last report, if available. This will be used
|
||||
// in captive portal detection and DERP flapping suppression. Ideally this would
|
||||
// be the current active home DERP rather than the last report preferred DERP,
|
||||
// but only the latter is presently available.
|
||||
var preferredDERP int
|
||||
if last != nil {
|
||||
preferredDERP = last.PreferredDERP
|
||||
@@ -847,7 +886,7 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
|
||||
|
||||
var plan probePlan
|
||||
if opts == nil || !opts.OnlyTCP443 {
|
||||
plan = makeProbePlan(dm, ifState, last)
|
||||
plan = makeProbePlan(dm, ifState, last, preferredDERP)
|
||||
}
|
||||
|
||||
// If we're doing a full probe, also check for a captive portal. We
|
||||
@@ -940,7 +979,7 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
|
||||
}
|
||||
}
|
||||
if len(need) > 0 {
|
||||
if !opts.OnlyTCP443 {
|
||||
if opts == nil || !opts.OnlyTCP443 {
|
||||
// Kick off ICMP in parallel to HTTPS checks; we don't
|
||||
// reuse the same WaitGroup for those probes because we
|
||||
// need to close the underlying Pinger after a timeout
|
||||
@@ -1297,6 +1336,7 @@ func (c *Client) addReportHistoryAndSetPreferredDERP(rs *reportState, r *Report,
|
||||
c.prev = map[time.Time]*Report{}
|
||||
}
|
||||
now := c.timeNow()
|
||||
r.Now = now.UTC()
|
||||
c.prev[now] = r
|
||||
c.last = r
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ func newTestClient(t testing.TB) *Client {
|
||||
c := &Client{
|
||||
NetMon: netmon.NewStatic(),
|
||||
Logf: t.Logf,
|
||||
TimeNow: func() time.Time {
|
||||
return time.Unix(1729624521, 0)
|
||||
},
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -38,7 +41,7 @@ func TestBasic(t *testing.T) {
|
||||
|
||||
c := newTestClient(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := c.Standalone(ctx, "127.0.0.1:0"); err != nil {
|
||||
@@ -52,6 +55,9 @@ func TestBasic(t *testing.T) {
|
||||
if !r.UDP {
|
||||
t.Error("want UDP")
|
||||
}
|
||||
if r.Now.IsZero() {
|
||||
t.Error("Now is zero")
|
||||
}
|
||||
if len(r.RegionLatency) != 1 {
|
||||
t.Errorf("expected 1 key in DERPLatency; got %+v", r.RegionLatency)
|
||||
}
|
||||
@@ -117,7 +123,7 @@ func TestWorksWhenUDPBlocked(t *testing.T) {
|
||||
|
||||
c := newTestClient(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
r, err := c.GetReport(ctx, dm, nil)
|
||||
@@ -130,6 +136,14 @@ func TestWorksWhenUDPBlocked(t *testing.T) {
|
||||
|
||||
want := newReport()
|
||||
|
||||
// The Now field can't be compared with reflect.DeepEqual; check using
|
||||
// the Equal method and then overwrite it so that the comparison below
|
||||
// succeeds.
|
||||
if !r.Now.Equal(c.TimeNow()) {
|
||||
t.Errorf("Now = %v; want %v", r.Now, c.TimeNow())
|
||||
}
|
||||
want.Now = r.Now
|
||||
|
||||
// The IPv4CanSend flag gets set differently across platforms.
|
||||
// On Windows this test detects false, while on Linux detects true.
|
||||
// That's not relevant to this test, so just accept what we're
|
||||
@@ -343,6 +357,15 @@ func TestAddReportHistoryAndSetPreferredDERP(t *testing.T) {
|
||||
wantPrevLen: 3,
|
||||
wantDERP: 2, // moved to d2 since d1 is gone
|
||||
},
|
||||
{
|
||||
name: "preferred_derp_hysteresis_no_switch_pct",
|
||||
steps: []step{
|
||||
{0 * time.Second, report("d1", 34*time.Millisecond, "d2", 35*time.Millisecond)},
|
||||
{1 * time.Second, report("d1", 34*time.Millisecond, "d2", 23*time.Millisecond)},
|
||||
},
|
||||
wantPrevLen: 2,
|
||||
wantDERP: 1, // diff is 11ms, but d2 is greater than 2/3s of d1
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -576,6 +599,40 @@ func TestMakeProbePlan(t *testing.T) {
|
||||
"region-3-v4": []probe{p("3a", 4)},
|
||||
},
|
||||
},
|
||||
{
|
||||
// #13969: ensure that the prior/current home region is always included in
|
||||
// probe plans, so that we don't flap between regions due to a single major
|
||||
// netcheck having excluded the home region due to a spuriously high sample.
|
||||
name: "ensure_home_region_inclusion",
|
||||
dm: basicMap,
|
||||
have6if: true,
|
||||
last: &Report{
|
||||
RegionLatency: map[int]time.Duration{
|
||||
1: 50 * time.Millisecond,
|
||||
2: 20 * time.Millisecond,
|
||||
3: 30 * time.Millisecond,
|
||||
4: 40 * time.Millisecond,
|
||||
},
|
||||
RegionV4Latency: map[int]time.Duration{
|
||||
1: 50 * time.Millisecond,
|
||||
2: 20 * time.Millisecond,
|
||||
},
|
||||
RegionV6Latency: map[int]time.Duration{
|
||||
3: 30 * time.Millisecond,
|
||||
4: 40 * time.Millisecond,
|
||||
},
|
||||
PreferredDERP: 1,
|
||||
},
|
||||
want: probePlan{
|
||||
"region-1-v4": []probe{p("1a", 4), p("1a", 4, 60*ms), p("1a", 4, 220*ms), p("1a", 4, 330*ms)},
|
||||
"region-1-v6": []probe{p("1a", 6), p("1a", 6, 60*ms), p("1a", 6, 220*ms), p("1a", 6, 330*ms)},
|
||||
"region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)},
|
||||
"region-2-v6": []probe{p("2a", 6), p("2b", 6, 24*ms)},
|
||||
"region-3-v4": []probe{p("3a", 4), p("3b", 4, 36*ms)},
|
||||
"region-3-v6": []probe{p("3a", 6), p("3b", 6, 36*ms)},
|
||||
"region-4-v4": []probe{p("4a", 4)},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -583,7 +640,11 @@ func TestMakeProbePlan(t *testing.T) {
|
||||
HaveV6: tt.have6if,
|
||||
HaveV4: !tt.no4,
|
||||
}
|
||||
got := makeProbePlan(tt.dm, ifState, tt.last)
|
||||
preferredDERP := 0
|
||||
if tt.last != nil {
|
||||
preferredDERP = tt.last.PreferredDERP
|
||||
}
|
||||
got := makeProbePlan(tt.dm, ifState, tt.last, preferredDERP)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("unexpected plan; got:\n%v\nwant:\n%v\n", got, tt.want)
|
||||
}
|
||||
@@ -756,7 +817,7 @@ func TestSortRegions(t *testing.T) {
|
||||
report.RegionLatency[3] = time.Second * time.Duration(6)
|
||||
report.RegionLatency[4] = time.Second * time.Duration(0)
|
||||
report.RegionLatency[5] = time.Second * time.Duration(2)
|
||||
sortedMap := sortRegions(unsortedMap, report)
|
||||
sortedMap := sortRegions(unsortedMap, report, 0)
|
||||
|
||||
// Sorting by latency this should result in rid: 5, 2, 1, 3
|
||||
// rid 4 with latency 0 should be at the end
|
||||
@@ -872,3 +933,30 @@ func TestReportTimeouts(t *testing.T) {
|
||||
t.Errorf("ReportTimeout (%v) cannot be less than httpsProbeTimeout (%v)", ReportTimeout, httpsProbeTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoUDPNilGetReportOpts(t *testing.T) {
|
||||
blackhole, err := net.ListenPacket("udp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open blackhole STUN listener: %v", err)
|
||||
}
|
||||
defer blackhole.Close()
|
||||
|
||||
dm := stuntest.DERPMapOf(blackhole.LocalAddr().String())
|
||||
for _, region := range dm.Regions {
|
||||
for _, n := range region.Nodes {
|
||||
n.STUNOnly = false // exercise ICMP & HTTPS probing
|
||||
}
|
||||
}
|
||||
|
||||
c := newTestClient(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
r, err := c.GetReport(ctx, dm, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.UDP {
|
||||
t.Fatal("unexpected working UDP")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,12 @@ const (
|
||||
addrTypeNotSupported replyCode = 8
|
||||
)
|
||||
|
||||
// UDP conn default buffer size and read timeout.
|
||||
const (
|
||||
bufferSize = 8 * 1024
|
||||
readTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// Server is a SOCKS5 proxy server.
|
||||
type Server struct {
|
||||
// Logf optionally specifies the logger to use.
|
||||
@@ -143,7 +149,8 @@ type Conn struct {
|
||||
clientConn net.Conn
|
||||
request *request
|
||||
|
||||
udpClientAddr net.Addr
|
||||
udpClientAddr net.Addr
|
||||
udpTargetConns map[socksAddr]net.Conn
|
||||
}
|
||||
|
||||
// Run starts the new connection.
|
||||
@@ -276,15 +283,6 @@ func (c *Conn) handleUDP() error {
|
||||
}
|
||||
defer clientUDPConn.Close()
|
||||
|
||||
serverUDPConn, err := net.ListenPacket("udp", "[::]:0")
|
||||
if err != nil {
|
||||
res := errorResponse(generalFailure)
|
||||
buf, _ := res.marshal()
|
||||
c.clientConn.Write(buf)
|
||||
return err
|
||||
}
|
||||
defer serverUDPConn.Close()
|
||||
|
||||
bindAddr, bindPort, err := splitHostPort(clientUDPConn.LocalAddr().String())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -305,25 +303,32 @@ func (c *Conn) handleUDP() error {
|
||||
}
|
||||
c.clientConn.Write(buf)
|
||||
|
||||
return c.transferUDP(c.clientConn, clientUDPConn, serverUDPConn)
|
||||
return c.transferUDP(c.clientConn, clientUDPConn)
|
||||
}
|
||||
|
||||
func (c *Conn) transferUDP(associatedTCP net.Conn, clientConn net.PacketConn, targetConn net.PacketConn) error {
|
||||
func (c *Conn) transferUDP(associatedTCP net.Conn, clientConn net.PacketConn) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
const bufferSize = 8 * 1024
|
||||
const readTimeout = 5 * time.Second
|
||||
|
||||
// client -> target
|
||||
go func() {
|
||||
defer cancel()
|
||||
|
||||
c.udpTargetConns = make(map[socksAddr]net.Conn)
|
||||
// close all target udp connections when the client connection is closed
|
||||
defer func() {
|
||||
for _, conn := range c.udpTargetConns {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
buf := make([]byte, bufferSize)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
err := c.handleUDPRequest(clientConn, targetConn, buf, readTimeout)
|
||||
err := c.handleUDPRequest(ctx, clientConn, buf)
|
||||
if err != nil {
|
||||
if isTimeout(err) {
|
||||
continue
|
||||
@@ -337,29 +342,6 @@ func (c *Conn) transferUDP(associatedTCP net.Conn, clientConn net.PacketConn, ta
|
||||
}
|
||||
}()
|
||||
|
||||
// target -> client
|
||||
go func() {
|
||||
defer cancel()
|
||||
buf := make([]byte, bufferSize)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
err := c.handleUDPResponse(targetConn, clientConn, buf, readTimeout)
|
||||
if err != nil {
|
||||
if isTimeout(err) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
c.logf("udp transfer: handle udp response fail: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// A UDP association terminates when the TCP connection that the UDP
|
||||
// ASSOCIATE request arrived on terminates. RFC1928
|
||||
_, err := io.Copy(io.Discard, associatedTCP)
|
||||
@@ -369,11 +351,50 @@ func (c *Conn) transferUDP(associatedTCP net.Conn, clientConn net.PacketConn, ta
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Conn) handleUDPRequest(
|
||||
func (c *Conn) getOrDialTargetConn(
|
||||
ctx context.Context,
|
||||
clientConn net.PacketConn,
|
||||
targetAddr socksAddr,
|
||||
) (net.Conn, error) {
|
||||
conn, exist := c.udpTargetConns[targetAddr]
|
||||
if exist {
|
||||
return conn, nil
|
||||
}
|
||||
conn, err := c.srv.dial(ctx, "udp", targetAddr.hostPort())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.udpTargetConns[targetAddr] = conn
|
||||
|
||||
// target -> client
|
||||
go func() {
|
||||
buf := make([]byte, bufferSize)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
err := c.handleUDPResponse(clientConn, targetAddr, conn, buf)
|
||||
if err != nil {
|
||||
if isTimeout(err) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) {
|
||||
return
|
||||
}
|
||||
c.logf("udp transfer: handle udp response fail: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *Conn) handleUDPRequest(
|
||||
ctx context.Context,
|
||||
clientConn net.PacketConn,
|
||||
targetConn net.PacketConn,
|
||||
buf []byte,
|
||||
readTimeout time.Duration,
|
||||
) error {
|
||||
// add a deadline for the read to avoid blocking forever
|
||||
_ = clientConn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||
@@ -386,38 +407,35 @@ func (c *Conn) handleUDPRequest(
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse udp request: %w", err)
|
||||
}
|
||||
targetAddr, err := net.ResolveUDPAddr("udp", req.addr.hostPort())
|
||||
|
||||
targetConn, err := c.getOrDialTargetConn(ctx, clientConn, req.addr)
|
||||
if err != nil {
|
||||
c.logf("resolve target addr fail: %v", err)
|
||||
return fmt.Errorf("dial target %s fail: %w", req.addr, err)
|
||||
}
|
||||
|
||||
nn, err := targetConn.WriteTo(data, targetAddr)
|
||||
nn, err := targetConn.Write(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write to target %s fail: %w", targetAddr, err)
|
||||
return fmt.Errorf("write to target %s fail: %w", req.addr, err)
|
||||
}
|
||||
if nn != len(data) {
|
||||
return fmt.Errorf("write to target %s fail: %w", targetAddr, io.ErrShortWrite)
|
||||
return fmt.Errorf("write to target %s fail: %w", req.addr, io.ErrShortWrite)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) handleUDPResponse(
|
||||
targetConn net.PacketConn,
|
||||
clientConn net.PacketConn,
|
||||
targetAddr socksAddr,
|
||||
targetConn net.Conn,
|
||||
buf []byte,
|
||||
readTimeout time.Duration,
|
||||
) error {
|
||||
// add a deadline for the read to avoid blocking forever
|
||||
_ = targetConn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||
n, addr, err := targetConn.ReadFrom(buf)
|
||||
n, err := targetConn.Read(buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read from target: %w", err)
|
||||
}
|
||||
host, port, err := splitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("split host port: %w", err)
|
||||
}
|
||||
hdr := udpRequest{addr: socksAddr{addrType: getAddrType(host), addr: host, port: port}}
|
||||
hdr := udpRequest{addr: targetAddr}
|
||||
pkt, err := hdr.marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal udp request: %w", err)
|
||||
@@ -627,10 +645,15 @@ func (s socksAddr) marshal() ([]byte, error) {
|
||||
pkt = binary.BigEndian.AppendUint16(pkt, s.port)
|
||||
return pkt, nil
|
||||
}
|
||||
|
||||
func (s socksAddr) hostPort() string {
|
||||
return net.JoinHostPort(s.addr, strconv.Itoa(int(s.port)))
|
||||
}
|
||||
|
||||
func (s socksAddr) String() string {
|
||||
return s.hostPort()
|
||||
}
|
||||
|
||||
// response contains the contents of
|
||||
// a response packet sent from the proxy
|
||||
// to the client.
|
||||
|
||||
@@ -169,12 +169,25 @@ func TestReadPassword(t *testing.T) {
|
||||
|
||||
func TestUDP(t *testing.T) {
|
||||
// backend UDP server which we'll use SOCKS5 to connect to
|
||||
listener, err := net.ListenPacket("udp", ":0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
newUDPEchoServer := func() net.PacketConn {
|
||||
listener, err := net.ListenPacket("udp", ":0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go udpEchoServer(listener)
|
||||
return listener
|
||||
}
|
||||
backendServerPort := listener.LocalAddr().(*net.UDPAddr).Port
|
||||
go udpEchoServer(listener)
|
||||
|
||||
const echoServerNumber = 3
|
||||
echoServerListener := make([]net.PacketConn, echoServerNumber)
|
||||
for i := 0; i < echoServerNumber; i++ {
|
||||
echoServerListener[i] = newUDPEchoServer()
|
||||
}
|
||||
defer func() {
|
||||
for i := 0; i < echoServerNumber; i++ {
|
||||
_ = echoServerListener[i].Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// SOCKS5 server
|
||||
socks5, err := net.Listen("tcp", ":0")
|
||||
@@ -184,84 +197,93 @@ func TestUDP(t *testing.T) {
|
||||
socks5Port := socks5.Addr().(*net.TCPAddr).Port
|
||||
go socks5Server(socks5)
|
||||
|
||||
// net/proxy don't support UDP, so we need to manually send the SOCKS5 UDP request
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", socks5Port))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = conn.Write([]byte{0x05, 0x01, 0x00}) // client hello with no auth
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 1024)
|
||||
n, err := conn.Read(buf) // server hello
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 || buf[0] != 0x05 || buf[1] != 0x00 {
|
||||
t.Fatalf("got: %q want: 0x05 0x00", buf[:n])
|
||||
// make a socks5 udpAssociate conn
|
||||
newUdpAssociateConn := func() (socks5Conn net.Conn, socks5UDPAddr socksAddr) {
|
||||
// net/proxy don't support UDP, so we need to manually send the SOCKS5 UDP request
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", socks5Port))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = conn.Write([]byte{socks5Version, 0x01, noAuthRequired}) // client hello with no auth
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 1024)
|
||||
n, err := conn.Read(buf) // server hello
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 || buf[0] != socks5Version || buf[1] != noAuthRequired {
|
||||
t.Fatalf("got: %q want: 0x05 0x00", buf[:n])
|
||||
}
|
||||
|
||||
targetAddr := socksAddr{addrType: ipv4, addr: "0.0.0.0", port: 0}
|
||||
targetAddrPkt, err := targetAddr.marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = conn.Write(append([]byte{socks5Version, byte(udpAssociate), 0x00}, targetAddrPkt...)) // client reqeust
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err = conn.Read(buf) // server response
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n < 3 || !bytes.Equal(buf[:3], []byte{socks5Version, 0x00, 0x00}) {
|
||||
t.Fatalf("got: %q want: 0x05 0x00 0x00", buf[:n])
|
||||
}
|
||||
udpProxySocksAddr, err := parseSocksAddr(bytes.NewReader(buf[3:n]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return conn, udpProxySocksAddr
|
||||
}
|
||||
|
||||
targetAddr := socksAddr{
|
||||
addrType: domainName,
|
||||
addr: "localhost",
|
||||
port: uint16(backendServerPort),
|
||||
}
|
||||
targetAddrPkt, err := targetAddr.marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = conn.Write(append([]byte{0x05, 0x03, 0x00}, targetAddrPkt...)) // client reqeust
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, udpProxySocksAddr := newUdpAssociateConn()
|
||||
defer conn.Close()
|
||||
|
||||
n, err = conn.Read(buf) // server response
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n < 3 || !bytes.Equal(buf[:3], []byte{0x05, 0x00, 0x00}) {
|
||||
t.Fatalf("got: %q want: 0x05 0x00 0x00", buf[:n])
|
||||
}
|
||||
udpProxySocksAddr, err := parseSocksAddr(bytes.NewReader(buf[3:n]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
sendUDPAndWaitResponse := func(socks5UDPConn net.Conn, addr socksAddr, body []byte) (responseBody []byte) {
|
||||
udpPayload, err := (&udpRequest{addr: addr}).marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
udpPayload = append(udpPayload, body...)
|
||||
_, err = socks5UDPConn.Write(udpPayload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 1024)
|
||||
n, err := socks5UDPConn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, responseBody, err = parseUDPRequest(buf[:n])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return responseBody
|
||||
}
|
||||
|
||||
udpProxyAddr, err := net.ResolveUDPAddr("udp", udpProxySocksAddr.hostPort())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
udpConn, err := net.DialUDP("udp", nil, udpProxyAddr)
|
||||
socks5UDPConn, err := net.DialUDP("udp", nil, udpProxyAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
udpPayload, err := (&udpRequest{addr: targetAddr}).marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
udpPayload = append(udpPayload, []byte("Test")...)
|
||||
_, err = udpConn.Write(udpPayload) // send udp package
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, _, err = udpConn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, responseBody, err := parseUDPRequest(buf[:n]) // read udp response
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(responseBody) != "Test" {
|
||||
t.Fatalf("got: %q want: Test", responseBody)
|
||||
}
|
||||
err = udpConn.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = conn.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
defer socks5UDPConn.Close()
|
||||
|
||||
for i := 0; i < echoServerNumber; i++ {
|
||||
port := echoServerListener[i].LocalAddr().(*net.UDPAddr).Port
|
||||
addr := socksAddr{addrType: ipv4, addr: "127.0.0.1", port: uint16(port)}
|
||||
requestBody := []byte(fmt.Sprintf("Test %d", i))
|
||||
responseBody := sendUDPAndWaitResponse(socks5UDPConn, addr, requestBody)
|
||||
if !bytes.Equal(requestBody, responseBody) {
|
||||
t.Fatalf("got: %q want: %q", responseBody, requestBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +279,13 @@ func setNetMon(netMon *netmon.Monitor) {
|
||||
if ifName == "" {
|
||||
return
|
||||
}
|
||||
ifIndex := state.Interface[ifName].Index
|
||||
// DefaultRouteInterface and Interface are gathered at different points in time.
|
||||
// Check for existence first, to avoid a nil pointer dereference.
|
||||
iface, ok := state.Interface[ifName]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ifIndex := iface.Index
|
||||
sockStats.mu.Lock()
|
||||
defer sockStats.mu.Unlock()
|
||||
// Ignore changes to unknown interfaces -- it would require
|
||||
|
||||
104
net/tlsdial/blockblame/blockblame.go
Normal file
104
net/tlsdial/blockblame/blockblame.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package blockblame blames specific firewall manufacturers for blocking Tailscale,
|
||||
// by analyzing the SSL certificate presented when attempting to connect to a remote
|
||||
// server.
|
||||
package blockblame
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifyCertificate checks if the given certificate c is issued by a firewall manufacturer
|
||||
// that is known to block Tailscale connections. It returns true and the Manufacturer of
|
||||
// the equipment if it is, or false and nil if it is not.
|
||||
func VerifyCertificate(c *x509.Certificate) (m *Manufacturer, ok bool) {
|
||||
for _, m := range Manufacturers {
|
||||
if m.match != nil && m.match(c) {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Manufacturer represents a firewall manufacturer that may be blocking Tailscale.
|
||||
type Manufacturer struct {
|
||||
// Name is the name of the firewall manufacturer to be
|
||||
// mentioned in health warning messages, e.g. "Fortinet".
|
||||
Name string
|
||||
// match is a function that returns true if the given certificate looks like it might
|
||||
// be issued by this manufacturer.
|
||||
match matchFunc
|
||||
}
|
||||
|
||||
var Manufacturers = []*Manufacturer{
|
||||
{
|
||||
Name: "Aruba Networks",
|
||||
match: issuerContains("Aruba"),
|
||||
},
|
||||
{
|
||||
Name: "Cisco",
|
||||
match: issuerContains("Cisco"),
|
||||
},
|
||||
{
|
||||
Name: "Fortinet",
|
||||
match: matchAny(
|
||||
issuerContains("Fortinet"),
|
||||
certEmail("support@fortinet.com"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "Huawei",
|
||||
match: certEmail("mobile@huawei.com"),
|
||||
},
|
||||
{
|
||||
Name: "Palo Alto Networks",
|
||||
match: matchAny(
|
||||
issuerContains("Palo Alto Networks"),
|
||||
issuerContains("PAN-FW"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "Sophos",
|
||||
match: issuerContains("Sophos"),
|
||||
},
|
||||
{
|
||||
Name: "Ubiquiti",
|
||||
match: matchAny(
|
||||
issuerContains("UniFi"),
|
||||
issuerContains("Ubiquiti"),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
type matchFunc func(*x509.Certificate) bool
|
||||
|
||||
func issuerContains(s string) matchFunc {
|
||||
return func(c *x509.Certificate) bool {
|
||||
return strings.Contains(strings.ToLower(c.Issuer.String()), strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
|
||||
func certEmail(v string) matchFunc {
|
||||
return func(c *x509.Certificate) bool {
|
||||
for _, email := range c.EmailAddresses {
|
||||
if strings.Contains(strings.ToLower(email), strings.ToLower(v)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func matchAny(fs ...matchFunc) matchFunc {
|
||||
return func(c *x509.Certificate) bool {
|
||||
for _, f := range fs {
|
||||
if f(c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
54
net/tlsdial/blockblame/blockblame_test.go
Normal file
54
net/tlsdial/blockblame/blockblame_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package blockblame
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const controlplaneDotTailscaleDotComPEM = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDkzCCAxqgAwIBAgISA2GOahsftpp59yuHClbDuoduMAoGCCqGSM49BAMDMDIx
|
||||
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
|
||||
NjAeFw0yNDEwMTIxNjE2NDVaFw0yNTAxMTAxNjE2NDRaMCUxIzAhBgNVBAMTGmNv
|
||||
bnRyb2xwbGFuZS50YWlsc2NhbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcD
|
||||
QgAExfraDUc1t185zuGtZlnPDtEJJSDBqvHN4vQcXSzSTPSAdDYHcA8fL5woU2Kg
|
||||
jK/2C0wm/rYy2Rre/ulhkS4wB6OCAhswggIXMA4GA1UdDwEB/wQEAwIHgDAdBgNV
|
||||
HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4E
|
||||
FgQUpArnpDj8Yh6NTgMOZjDPx0TuLmcwHwYDVR0jBBgwFoAUkydGmAOpUWiOmNbE
|
||||
QkjbI79YlNIwVQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzABhhVodHRwOi8vZTYu
|
||||
by5sZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lNi5pLmxlbmNyLm9yZy8w
|
||||
JQYDVR0RBB4wHIIaY29udHJvbHBsYW5lLnRhaWxzY2FsZS5jb20wEwYDVR0gBAww
|
||||
CjAIBgZngQwBAgEwggEDBgorBgEEAdZ5AgQCBIH0BIHxAO8AdgDgkrP8DB3I52g2
|
||||
H95huZZNClJ4GYpy1nLEsE2lbW9UBAAAAZKBujCyAAAEAwBHMEUCIQDHMgUaL4H9
|
||||
ZJa090ZOpBeEVu3+t+EF4HlHI1NqAai6uQIgeY/lLfjAXfcVgxBHHR4zjd0SzhaP
|
||||
TREHXzwxzN/8blkAdQDPEVbu1S58r/OHW9lpLpvpGnFnSrAX7KwB0lt3zsw7CAAA
|
||||
AZKBujh8AAAEAwBGMEQCICQwhMk45t9aiFjfwOC/y6+hDbszqSCpIv63kFElweUy
|
||||
AiAqTdkqmbqUVpnav5JdWkNERVAIlY4jqrThLsCLZYbNszAKBggqhkjOPQQDAwNn
|
||||
ADBkAjALyfgAt1XQp1uSfxy4GapR5OsmjEMBRVq6IgsPBlCRBfmf0Q3/a6mF0pjb
|
||||
Sj4oa+cCMEhZk4DmBTIdZY9zjuh8s7bXNfKxUQS0pEhALtXqyFr+D5dF7JcQo9+s
|
||||
Z98JY7/PCA==
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
func TestVerifyCertificateOurControlPlane(t *testing.T) {
|
||||
p, _ := pem.Decode([]byte(controlplaneDotTailscaleDotComPEM))
|
||||
if p == nil {
|
||||
t.Fatalf("failed to extract certificate bytes for controlplane.tailscale.com")
|
||||
return
|
||||
}
|
||||
cert, err := x509.ParseCertificate(p.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse certificate: %v", err)
|
||||
return
|
||||
}
|
||||
m, found := VerifyCertificate(cert)
|
||||
if found {
|
||||
t.Fatalf("expected to not get a result for the controlplane.tailscale.com certificate")
|
||||
}
|
||||
if m != nil {
|
||||
t.Fatalf("expected nil manufacturer for controlplane.tailscale.com certificate")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/net/tlsdial/blockblame"
|
||||
)
|
||||
|
||||
var counterFallbackOK int32 // atomic
|
||||
@@ -44,6 +45,16 @@ var debug = envknob.RegisterBool("TS_DEBUG_TLS_DIAL")
|
||||
// Headscale, etc.
|
||||
var tlsdialWarningPrinted sync.Map // map[string]bool
|
||||
|
||||
var mitmBlockWarnable = health.Register(&health.Warnable{
|
||||
Code: "blockblame-mitm-detected",
|
||||
Title: "Network may be blocking Tailscale",
|
||||
Text: func(args health.Args) string {
|
||||
return fmt.Sprintf("Network equipment from %q may be blocking Tailscale traffic on this network. Connect to another network, or contact your network administrator for assistance.", args["manufacturer"])
|
||||
},
|
||||
Severity: health.SeverityMedium,
|
||||
ImpactsConnectivity: true,
|
||||
})
|
||||
|
||||
// Config returns a tls.Config for connecting to a server.
|
||||
// If base is non-nil, it's cloned as the base config before
|
||||
// being configured and returned.
|
||||
@@ -86,12 +97,29 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
|
||||
|
||||
// Perform some health checks on this certificate before we do
|
||||
// any verification.
|
||||
var cert *x509.Certificate
|
||||
var selfSignedIssuer string
|
||||
if certs := cs.PeerCertificates; len(certs) > 0 && certIsSelfSigned(certs[0]) {
|
||||
selfSignedIssuer = certs[0].Issuer.String()
|
||||
if certs := cs.PeerCertificates; len(certs) > 0 {
|
||||
cert = certs[0]
|
||||
if certIsSelfSigned(cert) {
|
||||
selfSignedIssuer = cert.Issuer.String()
|
||||
}
|
||||
}
|
||||
if ht != nil {
|
||||
defer func() {
|
||||
if retErr != nil && cert != nil {
|
||||
// Is it a MITM SSL certificate from a well-known network appliance manufacturer?
|
||||
// Show a dedicated warning.
|
||||
m, ok := blockblame.VerifyCertificate(cert)
|
||||
if ok {
|
||||
log.Printf("tlsdial: server cert for %q looks like %q equipment (could be blocking Tailscale)", host, m.Name)
|
||||
ht.SetUnhealthy(mitmBlockWarnable, health.Args{"manufacturer": m.Name})
|
||||
} else {
|
||||
ht.SetHealthy(mitmBlockWarnable)
|
||||
}
|
||||
} else {
|
||||
ht.SetHealthy(mitmBlockWarnable)
|
||||
}
|
||||
if retErr != nil && selfSignedIssuer != "" {
|
||||
// Self-signed certs are never valid.
|
||||
//
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package tstun
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -20,10 +21,14 @@ import (
|
||||
"gvisor.dev/gvisor/pkg/tcpip/checksum"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
|
||||
"tailscale.com/net/netaddr"
|
||||
"tailscale.com/net/packet"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/multierr"
|
||||
)
|
||||
|
||||
@@ -35,13 +40,13 @@ var ourMAC = net.HardwareAddr{0x30, 0x2D, 0x66, 0xEC, 0x7A, 0x93}
|
||||
|
||||
func init() { createTAP = createTAPLinux }
|
||||
|
||||
func createTAPLinux(tapName, bridgeName string) (tun.Device, error) {
|
||||
func createTAPLinux(logf logger.Logf, tapName, bridgeName string) (tun.Device, error) {
|
||||
fd, err := unix.Open("/dev/net/tun", unix.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dev, err := openDevice(fd, tapName, bridgeName)
|
||||
dev, err := openDevice(logf, fd, tapName, bridgeName)
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
@@ -50,7 +55,7 @@ func createTAPLinux(tapName, bridgeName string) (tun.Device, error) {
|
||||
return dev, nil
|
||||
}
|
||||
|
||||
func openDevice(fd int, tapName, bridgeName string) (tun.Device, error) {
|
||||
func openDevice(logf logger.Logf, fd int, tapName, bridgeName string) (tun.Device, error) {
|
||||
ifr, err := unix.NewIfreq(tapName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -71,7 +76,7 @@ func openDevice(fd int, tapName, bridgeName string) (tun.Device, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return newTAPDevice(fd, tapName)
|
||||
return newTAPDevice(logf, fd, tapName)
|
||||
}
|
||||
|
||||
type etherType [2]byte
|
||||
@@ -91,7 +96,7 @@ const (
|
||||
|
||||
// handleTAPFrame handles receiving a raw TAP ethernet frame and reports whether
|
||||
// it's been handled (that is, whether it should NOT be passed to wireguard).
|
||||
func (t *Wrapper) handleTAPFrame(ethBuf []byte) bool {
|
||||
func (t *tapDevice) handleTAPFrame(ethBuf []byte) bool {
|
||||
|
||||
if len(ethBuf) < ethernetFrameSize {
|
||||
// Corrupt. Ignore.
|
||||
@@ -154,7 +159,7 @@ func (t *Wrapper) handleTAPFrame(ethBuf []byte) bool {
|
||||
|
||||
// If the client's asking about their own IP, tell them it's
|
||||
// their own MAC. TODO(bradfitz): remove String allocs.
|
||||
if net.IP(req.ProtocolAddressTarget()).String() == theClientIP {
|
||||
if net.IP(req.ProtocolAddressTarget()).String() == t.clientIPv4.Load() {
|
||||
copy(res.HardwareAddressSender(), ethSrcMAC)
|
||||
} else {
|
||||
copy(res.HardwareAddressSender(), ourMAC[:])
|
||||
@@ -164,8 +169,7 @@ func (t *Wrapper) handleTAPFrame(ethBuf []byte) bool {
|
||||
copy(res.HardwareAddressTarget(), req.HardwareAddressSender())
|
||||
copy(res.ProtocolAddressTarget(), req.ProtocolAddressSender())
|
||||
|
||||
// TODO(raggi): reduce allocs!
|
||||
n, err := t.tdev.Write([][]byte{buf}, 0)
|
||||
n, err := t.WriteEthernet(buf)
|
||||
if tapDebug {
|
||||
t.logf("tap: wrote ARP reply %v, %v", n, err)
|
||||
}
|
||||
@@ -175,14 +179,17 @@ func (t *Wrapper) handleTAPFrame(ethBuf []byte) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(bradfitz): remove these hard-coded values and move from a /24 to a /10 CGNAT as the range.
|
||||
const theClientIP = "100.70.145.3" // TODO: make dynamic from netmap
|
||||
const routerIP = "100.70.145.1" // must be in same netmask (currently hack at /24) as theClientIP
|
||||
var (
|
||||
// routerIP is the IP address of the DHCP server.
|
||||
routerIP = net.ParseIP(tsaddr.TailscaleServiceIPString)
|
||||
// cgnatNetMask is the netmask of the 100.64.0.0/10 CGNAT range.
|
||||
cgnatNetMask = net.IPMask(net.ParseIP("255.192.0.0").To4())
|
||||
)
|
||||
|
||||
// handleDHCPRequest handles receiving a raw TAP ethernet frame and reports whether
|
||||
// it's been handled as a DHCP request. That is, it reports whether the frame should
|
||||
// be ignored by the caller and not passed on.
|
||||
func (t *Wrapper) handleDHCPRequest(ethBuf []byte) bool {
|
||||
func (t *tapDevice) handleDHCPRequest(ethBuf []byte) bool {
|
||||
const udpHeader = 8
|
||||
if len(ethBuf) < ethernetFrameSize+ipv4HeaderLen+udpHeader {
|
||||
if tapDebug {
|
||||
@@ -207,7 +214,7 @@ func (t *Wrapper) handleDHCPRequest(ethBuf []byte) bool {
|
||||
if p.IPProto != ipproto.UDP || p.Src.Port() != 68 || p.Dst.Port() != 67 {
|
||||
// Not a DHCP request.
|
||||
if tapDebug {
|
||||
t.logf("tap: DHCP wrong meta")
|
||||
t.logf("tap: DHCP wrong meta: %+v", p)
|
||||
}
|
||||
return passOnPacket
|
||||
}
|
||||
@@ -225,17 +232,22 @@ func (t *Wrapper) handleDHCPRequest(ethBuf []byte) bool {
|
||||
}
|
||||
switch dp.MessageType() {
|
||||
case dhcpv4.MessageTypeDiscover:
|
||||
ips := t.clientIPv4.Load()
|
||||
if ips == "" {
|
||||
t.logf("tap: DHCP no client IP")
|
||||
return consumePacket
|
||||
}
|
||||
offer, err := dhcpv4.New(
|
||||
dhcpv4.WithReply(dp),
|
||||
dhcpv4.WithMessageType(dhcpv4.MessageTypeOffer),
|
||||
dhcpv4.WithRouter(net.ParseIP(routerIP)), // the default route
|
||||
dhcpv4.WithDNS(net.ParseIP("100.100.100.100")),
|
||||
dhcpv4.WithServerIP(net.ParseIP("100.100.100.100")), // TODO: what is this?
|
||||
dhcpv4.WithOption(dhcpv4.OptServerIdentifier(net.ParseIP("100.100.100.100"))),
|
||||
dhcpv4.WithYourIP(net.ParseIP(theClientIP)),
|
||||
dhcpv4.WithRouter(routerIP), // the default route
|
||||
dhcpv4.WithDNS(routerIP),
|
||||
dhcpv4.WithServerIP(routerIP), // TODO: what is this?
|
||||
dhcpv4.WithOption(dhcpv4.OptServerIdentifier(routerIP)),
|
||||
dhcpv4.WithYourIP(net.ParseIP(ips)),
|
||||
dhcpv4.WithLeaseTime(3600), // hour works
|
||||
//dhcpv4.WithHwAddr(ethSrcMAC),
|
||||
dhcpv4.WithNetmask(net.IPMask(net.ParseIP("255.255.255.0").To4())), // TODO: wrong
|
||||
dhcpv4.WithNetmask(cgnatNetMask),
|
||||
//dhcpv4.WithTransactionID(dp.TransactionID),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -250,22 +262,26 @@ func (t *Wrapper) handleDHCPRequest(ethBuf []byte) bool {
|
||||
netip.AddrPortFrom(netaddr.IPv4(255, 255, 255, 255), 68), // dst
|
||||
)
|
||||
|
||||
// TODO(raggi): reduce allocs!
|
||||
n, err := t.tdev.Write([][]byte{pkt}, 0)
|
||||
n, err := t.WriteEthernet(pkt)
|
||||
if tapDebug {
|
||||
t.logf("tap: wrote DHCP OFFER %v, %v", n, err)
|
||||
}
|
||||
case dhcpv4.MessageTypeRequest:
|
||||
ips := t.clientIPv4.Load()
|
||||
if ips == "" {
|
||||
t.logf("tap: DHCP no client IP")
|
||||
return consumePacket
|
||||
}
|
||||
ack, err := dhcpv4.New(
|
||||
dhcpv4.WithReply(dp),
|
||||
dhcpv4.WithMessageType(dhcpv4.MessageTypeAck),
|
||||
dhcpv4.WithDNS(net.ParseIP("100.100.100.100")),
|
||||
dhcpv4.WithRouter(net.ParseIP(routerIP)), // the default route
|
||||
dhcpv4.WithServerIP(net.ParseIP("100.100.100.100")), // TODO: what is this?
|
||||
dhcpv4.WithOption(dhcpv4.OptServerIdentifier(net.ParseIP("100.100.100.100"))),
|
||||
dhcpv4.WithYourIP(net.ParseIP(theClientIP)), // Hello world
|
||||
dhcpv4.WithLeaseTime(3600), // hour works
|
||||
dhcpv4.WithNetmask(net.IPMask(net.ParseIP("255.255.255.0").To4())),
|
||||
dhcpv4.WithDNS(routerIP),
|
||||
dhcpv4.WithRouter(routerIP), // the default route
|
||||
dhcpv4.WithServerIP(routerIP), // TODO: what is this?
|
||||
dhcpv4.WithOption(dhcpv4.OptServerIdentifier(routerIP)),
|
||||
dhcpv4.WithYourIP(net.ParseIP(ips)), // Hello world
|
||||
dhcpv4.WithLeaseTime(3600), // hour works
|
||||
dhcpv4.WithNetmask(cgnatNetMask),
|
||||
)
|
||||
if err != nil {
|
||||
t.logf("error building DHCP ack: %v", err)
|
||||
@@ -278,8 +294,7 @@ func (t *Wrapper) handleDHCPRequest(ethBuf []byte) bool {
|
||||
netip.AddrPortFrom(netaddr.IPv4(100, 100, 100, 100), 67), // src
|
||||
netip.AddrPortFrom(netaddr.IPv4(255, 255, 255, 255), 68), // dst
|
||||
)
|
||||
// TODO(raggi): reduce allocs!
|
||||
n, err := t.tdev.Write([][]byte{pkt}, 0)
|
||||
n, err := t.WriteEthernet(pkt)
|
||||
if tapDebug {
|
||||
t.logf("tap: wrote DHCP ACK %v, %v", n, err)
|
||||
}
|
||||
@@ -291,6 +306,16 @@ func (t *Wrapper) handleDHCPRequest(ethBuf []byte) bool {
|
||||
return consumePacket
|
||||
}
|
||||
|
||||
func writeEthernetFrame(buf []byte, srcMAC, dstMAC net.HardwareAddr, proto tcpip.NetworkProtocolNumber) {
|
||||
// Ethernet header
|
||||
eth := header.Ethernet(buf)
|
||||
eth.Encode(&header.EthernetFields{
|
||||
SrcAddr: tcpip.LinkAddress(srcMAC),
|
||||
DstAddr: tcpip.LinkAddress(dstMAC),
|
||||
Type: proto,
|
||||
})
|
||||
}
|
||||
|
||||
func packLayer2UDP(payload []byte, srcMAC, dstMAC net.HardwareAddr, src, dst netip.AddrPort) []byte {
|
||||
buf := make([]byte, header.EthernetMinimumSize+header.UDPMinimumSize+header.IPv4MinimumSize+len(payload))
|
||||
payloadStart := len(buf) - len(payload)
|
||||
@@ -300,12 +325,7 @@ func packLayer2UDP(payload []byte, srcMAC, dstMAC net.HardwareAddr, src, dst net
|
||||
dstB := dst.Addr().As4()
|
||||
dstIP := tcpip.AddrFromSlice(dstB[:])
|
||||
// Ethernet header
|
||||
eth := header.Ethernet(buf)
|
||||
eth.Encode(&header.EthernetFields{
|
||||
SrcAddr: tcpip.LinkAddress(srcMAC),
|
||||
DstAddr: tcpip.LinkAddress(dstMAC),
|
||||
Type: ipv4.ProtocolNumber,
|
||||
})
|
||||
writeEthernetFrame(buf, srcMAC, dstMAC, ipv4.ProtocolNumber)
|
||||
// IP header
|
||||
ipbuf := buf[header.EthernetMinimumSize:]
|
||||
ip := header.IPv4(ipbuf)
|
||||
@@ -342,17 +362,18 @@ func run(prog string, args ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Wrapper) destMAC() [6]byte {
|
||||
func (t *tapDevice) destMAC() [6]byte {
|
||||
return t.destMACAtomic.Load()
|
||||
}
|
||||
|
||||
func newTAPDevice(fd int, tapName string) (tun.Device, error) {
|
||||
func newTAPDevice(logf logger.Logf, fd int, tapName string) (tun.Device, error) {
|
||||
err := unix.SetNonblock(fd, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), "/dev/tap")
|
||||
d := &tapDevice{
|
||||
logf: logf,
|
||||
file: file,
|
||||
events: make(chan tun.Event),
|
||||
name: tapName,
|
||||
@@ -360,20 +381,22 @@ func newTAPDevice(fd int, tapName string) (tun.Device, error) {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ setWrapperer = &tapDevice{}
|
||||
)
|
||||
|
||||
type tapDevice struct {
|
||||
file *os.File
|
||||
events chan tun.Event
|
||||
name string
|
||||
wrapper *Wrapper
|
||||
closeOnce sync.Once
|
||||
file *os.File
|
||||
logf func(format string, args ...any)
|
||||
events chan tun.Event
|
||||
name string
|
||||
closeOnce sync.Once
|
||||
clientIPv4 syncs.AtomicValue[string]
|
||||
|
||||
destMACAtomic syncs.AtomicValue[[6]byte]
|
||||
}
|
||||
|
||||
func (t *tapDevice) setWrapper(wrapper *Wrapper) {
|
||||
t.wrapper = wrapper
|
||||
var _ setIPer = (*tapDevice)(nil)
|
||||
|
||||
func (t *tapDevice) SetIP(ipV4, ipV6TODO netip.Addr) error {
|
||||
t.clientIPv4.Store(ipV4.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tapDevice) File() *os.File {
|
||||
@@ -384,36 +407,63 @@ func (t *tapDevice) Name() (string, error) {
|
||||
return t.name, nil
|
||||
}
|
||||
|
||||
// Read reads an IP packet from the TAP device. It strips the ethernet frame header.
|
||||
func (t *tapDevice) Read(buffs [][]byte, sizes []int, offset int) (int, error) {
|
||||
n, err := t.ReadEthernet(buffs, sizes, offset)
|
||||
if err != nil || n == 0 {
|
||||
return n, err
|
||||
}
|
||||
// Strip the ethernet frame header.
|
||||
copy(buffs[0][offset:], buffs[0][offset+ethernetFrameSize:offset+sizes[0]])
|
||||
sizes[0] -= ethernetFrameSize
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// ReadEthernet reads a raw ethernet frame from the TAP device.
|
||||
func (t *tapDevice) ReadEthernet(buffs [][]byte, sizes []int, offset int) (int, error) {
|
||||
n, err := t.file.Read(buffs[0][offset:])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if t.handleTAPFrame(buffs[0][offset : offset+n]) {
|
||||
return 0, nil
|
||||
}
|
||||
sizes[0] = n
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// WriteEthernet writes a raw ethernet frame to the TAP device.
|
||||
func (t *tapDevice) WriteEthernet(buf []byte) (int, error) {
|
||||
return t.file.Write(buf)
|
||||
}
|
||||
|
||||
// ethBufPool holds a pool of bytes.Buffers for use in [tapDevice.Write].
|
||||
var ethBufPool = syncs.Pool[*bytes.Buffer]{New: func() *bytes.Buffer { return new(bytes.Buffer) }}
|
||||
|
||||
// Write writes a raw IP packet to the TAP device. It adds the ethernet frame header.
|
||||
func (t *tapDevice) Write(buffs [][]byte, offset int) (int, error) {
|
||||
errs := make([]error, 0)
|
||||
wrote := 0
|
||||
m := t.destMAC()
|
||||
dstMac := net.HardwareAddr(m[:])
|
||||
buf := ethBufPool.Get()
|
||||
defer ethBufPool.Put(buf)
|
||||
for _, buff := range buffs {
|
||||
if offset < ethernetFrameSize {
|
||||
errs = append(errs, fmt.Errorf("[unexpected] weird offset %d for TAP write", offset))
|
||||
return 0, multierr.New(errs...)
|
||||
buf.Reset()
|
||||
buf.Grow(header.EthernetMinimumSize + len(buff) - offset)
|
||||
|
||||
var ebuf [14]byte
|
||||
switch buff[offset] >> 4 {
|
||||
case 4:
|
||||
writeEthernetFrame(ebuf[:], ourMAC, dstMac, ipv4.ProtocolNumber)
|
||||
case 6:
|
||||
writeEthernetFrame(ebuf[:], ourMAC, dstMac, ipv6.ProtocolNumber)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
eth := buff[offset-ethernetFrameSize:]
|
||||
dst := t.wrapper.destMAC()
|
||||
copy(eth[:6], dst[:])
|
||||
copy(eth[6:12], ourMAC[:])
|
||||
et := etherTypeIPv4
|
||||
if buff[offset]>>4 == 6 {
|
||||
et = etherTypeIPv6
|
||||
}
|
||||
eth[12], eth[13] = et[0], et[1]
|
||||
if tapDebug {
|
||||
t.wrapper.logf("tap: tapWrite off=%v % x", offset, buff)
|
||||
}
|
||||
_, err := t.file.Write(buff[offset-ethernetFrameSize:])
|
||||
buf.Write(ebuf[:])
|
||||
buf.Write(buff[offset:])
|
||||
_, err := t.WriteEthernet(buf.Bytes())
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
@@ -428,8 +478,7 @@ func (t *tapDevice) MTU() (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err = unix.IoctlIfreq(int(t.file.Fd()), unix.SIOCGIFMTU, ifr)
|
||||
if err != nil {
|
||||
if err := unix.IoctlIfreq(int(t.file.Fd()), unix.SIOCGIFMTU, ifr); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(ifr.Uint32()), nil
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !linux || ts_omit_tap
|
||||
|
||||
package tstun
|
||||
|
||||
func (*Wrapper) handleTAPFrame([]byte) bool { panic("unreachable") }
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// createTAP is non-nil on Linux.
|
||||
var createTAP func(tapName, bridgeName string) (tun.Device, error)
|
||||
var createTAP func(logf logger.Logf, tapName, bridgeName string) (tun.Device, error)
|
||||
|
||||
// New returns a tun.Device for the requested device name, along with
|
||||
// the OS-dependent name that was allocated to the device.
|
||||
@@ -42,7 +42,7 @@ func New(logf logger.Logf, tunName string) (tun.Device, string, error) {
|
||||
default:
|
||||
return nil, "", errors.New("bogus tap argument")
|
||||
}
|
||||
dev, err = createTAP(tapName, bridgeName)
|
||||
dev, err = createTAP(logf, tapName, bridgeName)
|
||||
} else {
|
||||
dev, err = tun.CreateTUN(tunName, int(DefaultTUNMTU()))
|
||||
}
|
||||
|
||||
@@ -109,9 +109,7 @@ type Wrapper struct {
|
||||
lastActivityAtomic mono.Time // time of last send or receive
|
||||
|
||||
destIPActivity syncs.AtomicValue[map[netip.Addr]func()]
|
||||
//lint:ignore U1000 used in tap_linux.go
|
||||
destMACAtomic syncs.AtomicValue[[6]byte]
|
||||
discoKey syncs.AtomicValue[key.DiscoPublic]
|
||||
discoKey syncs.AtomicValue[key.DiscoPublic]
|
||||
|
||||
// timeNow, if non-nil, will be used to obtain the current time.
|
||||
timeNow func() time.Time
|
||||
@@ -215,24 +213,14 @@ type Wrapper struct {
|
||||
}
|
||||
|
||||
type metrics struct {
|
||||
inboundDroppedPacketsTotal *tsmetrics.MultiLabelMap[dropPacketLabel]
|
||||
outboundDroppedPacketsTotal *tsmetrics.MultiLabelMap[dropPacketLabel]
|
||||
inboundDroppedPacketsTotal *tsmetrics.MultiLabelMap[usermetric.DropLabels]
|
||||
outboundDroppedPacketsTotal *tsmetrics.MultiLabelMap[usermetric.DropLabels]
|
||||
}
|
||||
|
||||
func registerMetrics(reg *usermetric.Registry) *metrics {
|
||||
return &metrics{
|
||||
inboundDroppedPacketsTotal: usermetric.NewMultiLabelMapWithRegistry[dropPacketLabel](
|
||||
reg,
|
||||
"tailscaled_inbound_dropped_packets_total",
|
||||
"counter",
|
||||
"Counts the number of dropped packets received by the node from other peers",
|
||||
),
|
||||
outboundDroppedPacketsTotal: usermetric.NewMultiLabelMapWithRegistry[dropPacketLabel](
|
||||
reg,
|
||||
"tailscaled_outbound_dropped_packets_total",
|
||||
"counter",
|
||||
"Counts the number of packets dropped while being sent to other peers",
|
||||
),
|
||||
inboundDroppedPacketsTotal: reg.DroppedPacketsInbound(),
|
||||
outboundDroppedPacketsTotal: reg.DroppedPacketsOutbound(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,12 +245,6 @@ type tunVectorReadResult struct {
|
||||
dataOffset int
|
||||
}
|
||||
|
||||
type setWrapperer interface {
|
||||
// setWrapper enables the underlying TUN/TAP to have access to the Wrapper.
|
||||
// It MUST be called only once during initialization, other usage is unsafe.
|
||||
setWrapper(*Wrapper)
|
||||
}
|
||||
|
||||
// Start unblocks any Wrapper.Read calls that have already started
|
||||
// and makes the Wrapper functional.
|
||||
//
|
||||
@@ -313,10 +295,6 @@ func wrap(logf logger.Logf, tdev tun.Device, isTAP bool, m *usermetric.Registry)
|
||||
w.bufferConsumed <- struct{}{}
|
||||
w.noteActivity()
|
||||
|
||||
if sw, ok := w.tdev.(setWrapperer); ok {
|
||||
sw.setWrapper(w)
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -459,12 +437,18 @@ const ethernetFrameSize = 14 // 2 six byte MACs, 2 bytes ethertype
|
||||
func (t *Wrapper) pollVector() {
|
||||
sizes := make([]int, len(t.vectorBuffer))
|
||||
readOffset := PacketStartOffset
|
||||
reader := t.tdev.Read
|
||||
if t.isTAP {
|
||||
readOffset = PacketStartOffset - ethernetFrameSize
|
||||
type tapReader interface {
|
||||
ReadEthernet(buffs [][]byte, sizes []int, offset int) (int, error)
|
||||
}
|
||||
if r, ok := t.tdev.(tapReader); ok {
|
||||
readOffset = PacketStartOffset - ethernetFrameSize
|
||||
reader = r.ReadEthernet
|
||||
}
|
||||
}
|
||||
|
||||
for range t.bufferConsumed {
|
||||
DoRead:
|
||||
for i := range t.vectorBuffer {
|
||||
t.vectorBuffer[i] = t.vectorBuffer[i][:cap(t.vectorBuffer[i])]
|
||||
}
|
||||
@@ -474,7 +458,7 @@ func (t *Wrapper) pollVector() {
|
||||
if t.isClosed() {
|
||||
return
|
||||
}
|
||||
n, err = t.tdev.Read(t.vectorBuffer[:], sizes, readOffset)
|
||||
n, err = reader(t.vectorBuffer[:], sizes, readOffset)
|
||||
if t.isTAP && tapDebug {
|
||||
s := fmt.Sprintf("% x", t.vectorBuffer[0][:])
|
||||
for strings.HasSuffix(s, " 00") {
|
||||
@@ -486,21 +470,6 @@ func (t *Wrapper) pollVector() {
|
||||
for i := range sizes[:n] {
|
||||
t.vectorBuffer[i] = t.vectorBuffer[i][:readOffset+sizes[i]]
|
||||
}
|
||||
if t.isTAP {
|
||||
if err == nil {
|
||||
ethernetFrame := t.vectorBuffer[0][readOffset:]
|
||||
if t.handleTAPFrame(ethernetFrame) {
|
||||
goto DoRead
|
||||
}
|
||||
}
|
||||
// Fall through. We got an IP packet.
|
||||
if sizes[0] >= ethernetFrameSize {
|
||||
t.vectorBuffer[0] = t.vectorBuffer[0][:readOffset+sizes[0]-ethernetFrameSize]
|
||||
}
|
||||
if tapDebug {
|
||||
t.logf("tap regular frame: %x", t.vectorBuffer[0][PacketStartOffset:PacketStartOffset+sizes[0]])
|
||||
}
|
||||
}
|
||||
t.sendVectorOutbound(tunVectorReadResult{
|
||||
data: t.vectorBuffer[:n],
|
||||
dataOffset: PacketStartOffset,
|
||||
@@ -823,10 +792,19 @@ func (pc *peerConfigTable) outboundPacketIsJailed(p *packet.Parsed) bool {
|
||||
return c.jailed
|
||||
}
|
||||
|
||||
type setIPer interface {
|
||||
// SetIP sets the IP addresses of the TAP device.
|
||||
SetIP(ipV4, ipV6 netip.Addr) error
|
||||
}
|
||||
|
||||
// SetWGConfig is called when a new NetworkMap is received.
|
||||
func (t *Wrapper) SetWGConfig(wcfg *wgcfg.Config) {
|
||||
if t.isTAP {
|
||||
if sip, ok := t.tdev.(setIPer); ok {
|
||||
sip.SetIP(findV4(wcfg.Addresses), findV6(wcfg.Addresses))
|
||||
}
|
||||
}
|
||||
cfg := peerConfigTableFromWGConfig(wcfg)
|
||||
|
||||
old := t.peerConfig.Swap(cfg)
|
||||
if !reflect.DeepEqual(old, cfg) {
|
||||
t.logf("peer config: %v", cfg)
|
||||
@@ -883,7 +861,22 @@ func (t *Wrapper) filterPacketOutboundToWireGuard(p *packet.Parsed, pc *peerConf
|
||||
return res, gro
|
||||
}
|
||||
}
|
||||
if resp := t.filtRunOut(p, pc); resp != filter.Accept {
|
||||
return resp, gro
|
||||
}
|
||||
|
||||
if t.PostFilterPacketOutboundToWireGuard != nil {
|
||||
if res := t.PostFilterPacketOutboundToWireGuard(p, t); res.IsDrop() {
|
||||
return res, gro
|
||||
}
|
||||
}
|
||||
return filter.Accept, gro
|
||||
}
|
||||
|
||||
// filtRunOut runs the outbound packet filter on p.
|
||||
// It uses pc to determine if the packet is to a jailed peer and should be
|
||||
// filtered with the jailed filter.
|
||||
func (t *Wrapper) filtRunOut(p *packet.Parsed, pc *peerConfigTable) filter.Response {
|
||||
// If the outbound packet is to a jailed peer, use our jailed peer
|
||||
// packet filter.
|
||||
var filt *filter.Filter
|
||||
@@ -893,23 +886,17 @@ func (t *Wrapper) filterPacketOutboundToWireGuard(p *packet.Parsed, pc *peerConf
|
||||
filt = t.filter.Load()
|
||||
}
|
||||
if filt == nil {
|
||||
return filter.Drop, gro
|
||||
return filter.Drop
|
||||
}
|
||||
|
||||
if filt.RunOut(p, t.filterFlags) != filter.Accept {
|
||||
metricPacketOutDropFilter.Add(1)
|
||||
t.metrics.outboundDroppedPacketsTotal.Add(dropPacketLabel{
|
||||
Reason: DropReasonACL,
|
||||
t.metrics.outboundDroppedPacketsTotal.Add(usermetric.DropLabels{
|
||||
Reason: usermetric.ReasonACL,
|
||||
}, 1)
|
||||
return filter.Drop, gro
|
||||
return filter.Drop
|
||||
}
|
||||
|
||||
if t.PostFilterPacketOutboundToWireGuard != nil {
|
||||
if res := t.PostFilterPacketOutboundToWireGuard(p, t); res.IsDrop() {
|
||||
return res, gro
|
||||
}
|
||||
}
|
||||
return filter.Accept, gro
|
||||
return filter.Accept
|
||||
}
|
||||
|
||||
// noteActivity records that there was a read or write at the current time.
|
||||
@@ -1073,6 +1060,11 @@ func (t *Wrapper) injectedRead(res tunInjectedRead, outBuffs [][]byte, sizes []i
|
||||
p := parsedPacketPool.Get().(*packet.Parsed)
|
||||
defer parsedPacketPool.Put(p)
|
||||
p.Decode(pkt)
|
||||
response, _ := t.filterPacketOutboundToWireGuard(p, pc, nil)
|
||||
if response != filter.Accept {
|
||||
metricPacketOutDrop.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
invertGSOChecksum(pkt, gso)
|
||||
pc.snat(p)
|
||||
@@ -1170,8 +1162,8 @@ func (t *Wrapper) filterPacketInboundFromWireGuard(p *packet.Parsed, captHook ca
|
||||
|
||||
if outcome != filter.Accept {
|
||||
metricPacketInDropFilter.Add(1)
|
||||
t.metrics.inboundDroppedPacketsTotal.Add(dropPacketLabel{
|
||||
Reason: DropReasonACL,
|
||||
t.metrics.inboundDroppedPacketsTotal.Add(usermetric.DropLabels{
|
||||
Reason: usermetric.ReasonACL,
|
||||
}, 1)
|
||||
|
||||
// Tell them, via TSMP, we're dropping them due to the ACL.
|
||||
@@ -1251,8 +1243,8 @@ func (t *Wrapper) Write(buffs [][]byte, offset int) (int, error) {
|
||||
t.noteActivity()
|
||||
_, err := t.tdevWrite(buffs, offset)
|
||||
if err != nil {
|
||||
t.metrics.inboundDroppedPacketsTotal.Add(dropPacketLabel{
|
||||
Reason: DropReasonError,
|
||||
t.metrics.inboundDroppedPacketsTotal.Add(usermetric.DropLabels{
|
||||
Reason: usermetric.ReasonError,
|
||||
}, int64(len(buffs)))
|
||||
}
|
||||
return len(buffs), err
|
||||
@@ -1494,20 +1486,6 @@ var (
|
||||
metricPacketOutDropSelfDisco = clientmetric.NewCounter("tstun_out_to_wg_drop_self_disco")
|
||||
)
|
||||
|
||||
type DropReason string
|
||||
|
||||
const (
|
||||
DropReasonACL DropReason = "acl"
|
||||
DropReasonError DropReason = "error"
|
||||
)
|
||||
|
||||
type dropPacketLabel struct {
|
||||
// Reason indicates what we have done with the packet, and has the following values:
|
||||
// - acl (rejected packets because of ACL)
|
||||
// - error (rejected packets because of an error)
|
||||
Reason DropReason
|
||||
}
|
||||
|
||||
func (t *Wrapper) InstallCaptureHook(cb capture.Callback) {
|
||||
t.captureHook.Store(cb)
|
||||
}
|
||||
|
||||
@@ -441,13 +441,13 @@ func TestFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
var metricInboundDroppedPacketsACL, metricInboundDroppedPacketsErr, metricOutboundDroppedPacketsACL int64
|
||||
if m, ok := tun.metrics.inboundDroppedPacketsTotal.Get(dropPacketLabel{Reason: DropReasonACL}).(*expvar.Int); ok {
|
||||
if m, ok := tun.metrics.inboundDroppedPacketsTotal.Get(usermetric.DropLabels{Reason: usermetric.ReasonACL}).(*expvar.Int); ok {
|
||||
metricInboundDroppedPacketsACL = m.Value()
|
||||
}
|
||||
if m, ok := tun.metrics.inboundDroppedPacketsTotal.Get(dropPacketLabel{Reason: DropReasonError}).(*expvar.Int); ok {
|
||||
if m, ok := tun.metrics.inboundDroppedPacketsTotal.Get(usermetric.DropLabels{Reason: usermetric.ReasonError}).(*expvar.Int); ok {
|
||||
metricInboundDroppedPacketsErr = m.Value()
|
||||
}
|
||||
if m, ok := tun.metrics.outboundDroppedPacketsTotal.Get(dropPacketLabel{Reason: DropReasonACL}).(*expvar.Int); ok {
|
||||
if m, ok := tun.metrics.outboundDroppedPacketsTotal.Get(usermetric.DropLabels{Reason: usermetric.ReasonACL}).(*expvar.Int); ok {
|
||||
metricOutboundDroppedPacketsACL = m.Value()
|
||||
}
|
||||
|
||||
|
||||
18
release/dist/synology/pkgs.go
vendored
18
release/dist/synology/pkgs.go
vendored
@@ -155,8 +155,22 @@ func (t *target) mkInfo(b *dist.Build, uncompressedSz int64) []byte {
|
||||
f("os_min_ver", "6.0.1-7445")
|
||||
f("os_max_ver", "7.0-40000")
|
||||
case 7:
|
||||
f("os_min_ver", "7.0-40000")
|
||||
f("os_max_ver", "")
|
||||
if t.packageCenter {
|
||||
switch t.dsmMinorVersion {
|
||||
case 0:
|
||||
f("os_min_ver", "7.0-40000")
|
||||
f("os_max_ver", "7.2-60000")
|
||||
case 2:
|
||||
f("os_min_ver", "7.2-60000")
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported DSM major.minor version %s", t.dsmVersionString()))
|
||||
}
|
||||
} else {
|
||||
// We do not clamp the os_max_ver currently for non-package center builds as
|
||||
// the binaries for 7.0 and 7.2 are identical.
|
||||
f("os_min_ver", "7.0-40000")
|
||||
f("os_max_ver", "")
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported DSM major version %d", t.dsmMajorVersion))
|
||||
}
|
||||
|
||||
105
safeweb/http.go
105
safeweb/http.go
@@ -74,25 +74,74 @@ import (
|
||||
crand "crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
)
|
||||
|
||||
// The default Content-Security-Policy header.
|
||||
var defaultCSP = strings.Join([]string{
|
||||
`default-src 'self'`, // origin is the only valid source for all content types
|
||||
`script-src 'self'`, // disallow inline javascript
|
||||
`frame-ancestors 'none'`, // disallow framing of the page
|
||||
`form-action 'self'`, // disallow form submissions to other origins
|
||||
`base-uri 'self'`, // disallow base URIs from other origins
|
||||
`block-all-mixed-content`, // disallow mixed content when serving over HTTPS
|
||||
`object-src 'self'`, // disallow embedding of resources from other origins
|
||||
}, "; ")
|
||||
// CSP is the value of a Content-Security-Policy header. Keys are CSP
|
||||
// directives (like "default-src") and values are source expressions (like
|
||||
// "'self'" or "https://tailscale.com"). A nil slice value is allowed for some
|
||||
// directives like "upgrade-insecure-requests" that don't expect a list of
|
||||
// source definitions.
|
||||
type CSP map[string][]string
|
||||
|
||||
// DefaultCSP is the recommended CSP to use when not loading resources from
|
||||
// other domains and not embedding the current website. If you need to tweak
|
||||
// the CSP, it is recommended to extend DefaultCSP instead of writing your own
|
||||
// from scratch.
|
||||
func DefaultCSP() CSP {
|
||||
return CSP{
|
||||
"default-src": {"self"}, // origin is the only valid source for all content types
|
||||
"frame-ancestors": {"none"}, // disallow framing of the page
|
||||
"form-action": {"self"}, // disallow form submissions to other origins
|
||||
"base-uri": {"self"}, // disallow base URIs from other origins
|
||||
// TODO(awly): consider upgrade-insecure-requests in SecureContext
|
||||
// instead, as this is deprecated.
|
||||
"block-all-mixed-content": nil, // disallow mixed content when serving over HTTPS
|
||||
}
|
||||
}
|
||||
|
||||
// Set sets the values for a given directive. Empty values are allowed, if the
|
||||
// directive doesn't expect any (like "upgrade-insecure-requests").
|
||||
func (csp CSP) Set(directive string, values ...string) {
|
||||
csp[directive] = values
|
||||
}
|
||||
|
||||
// Add adds a source expression to an existing directive.
|
||||
func (csp CSP) Add(directive, value string) {
|
||||
csp[directive] = append(csp[directive], value)
|
||||
}
|
||||
|
||||
// Del deletes a directive and all its values.
|
||||
func (csp CSP) Del(directive string) {
|
||||
delete(csp, directive)
|
||||
}
|
||||
|
||||
func (csp CSP) String() string {
|
||||
keys := slices.Collect(maps.Keys(csp))
|
||||
slices.Sort(keys)
|
||||
var s strings.Builder
|
||||
for _, k := range keys {
|
||||
s.WriteString(k)
|
||||
for _, v := range csp[k] {
|
||||
// Special values like 'self', 'none', 'unsafe-inline', etc., must
|
||||
// be quoted. Do it implicitly as a convenience here.
|
||||
if !strings.Contains(v, ".") && len(v) > 1 && v[0] != '\'' && v[len(v)-1] != '\'' {
|
||||
v = "'" + v + "'"
|
||||
}
|
||||
s.WriteString(" " + v)
|
||||
}
|
||||
s.WriteString("; ")
|
||||
}
|
||||
return strings.TrimSpace(s.String())
|
||||
}
|
||||
|
||||
// The default Strict-Transport-Security header. This header tells the browser
|
||||
// to exclusively use HTTPS for all requests to the origin for the next year.
|
||||
@@ -130,6 +179,9 @@ type Config struct {
|
||||
// startup.
|
||||
CSRFSecret []byte
|
||||
|
||||
// CSP is the Content-Security-Policy header to return with BrowserMux
|
||||
// responses.
|
||||
CSP CSP
|
||||
// CSPAllowInlineStyles specifies whether to include `style-src:
|
||||
// unsafe-inline` in the Content-Security-Policy header to permit the use of
|
||||
// inline CSS.
|
||||
@@ -168,6 +220,10 @@ func (c *Config) setDefaults() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.CSP == nil {
|
||||
c.CSP = DefaultCSP()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -199,16 +255,20 @@ func NewServer(config Config) (*Server, error) {
|
||||
if config.CookiesSameSiteLax {
|
||||
sameSite = csrf.SameSiteLaxMode
|
||||
}
|
||||
if config.CSPAllowInlineStyles {
|
||||
if _, ok := config.CSP["style-src"]; ok {
|
||||
config.CSP.Add("style-src", "unsafe-inline")
|
||||
} else {
|
||||
config.CSP.Set("style-src", "self", "unsafe-inline")
|
||||
}
|
||||
}
|
||||
s := &Server{
|
||||
Config: config,
|
||||
csp: defaultCSP,
|
||||
csp: config.CSP.String(),
|
||||
// only set Secure flag on CSRF cookies if we are in a secure context
|
||||
// as otherwise the browser will reject the cookie
|
||||
csrfProtect: csrf.Protect(config.CSRFSecret, csrf.Secure(config.SecureContext), csrf.SameSite(sameSite)),
|
||||
}
|
||||
if config.CSPAllowInlineStyles {
|
||||
s.csp = defaultCSP + `; style-src 'self' 'unsafe-inline'`
|
||||
}
|
||||
s.h = cmp.Or(config.HTTPServer, &http.Server{})
|
||||
if s.h.Handler != nil {
|
||||
return nil, fmt.Errorf("use safeweb.Config.APIMux and safeweb.Config.BrowserMux instead of http.Server.Handler")
|
||||
@@ -225,12 +285,27 @@ const (
|
||||
browserHandler
|
||||
)
|
||||
|
||||
func (h handlerType) String() string {
|
||||
switch h {
|
||||
case browserHandler:
|
||||
return "browser"
|
||||
case apiHandler:
|
||||
return "api"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// checkHandlerType returns either apiHandler or browserHandler, depending on
|
||||
// whether apiPattern or browserPattern is more specific (i.e. which pattern
|
||||
// contains more pathname components). If they are equally specific, it returns
|
||||
// unknownHandler.
|
||||
func checkHandlerType(apiPattern, browserPattern string) handlerType {
|
||||
c := cmp.Compare(strings.Count(path.Clean(apiPattern), "/"), strings.Count(path.Clean(browserPattern), "/"))
|
||||
apiPattern, browserPattern = path.Clean(apiPattern), path.Clean(browserPattern)
|
||||
c := cmp.Compare(strings.Count(apiPattern, "/"), strings.Count(browserPattern, "/"))
|
||||
if apiPattern == "/" || browserPattern == "/" {
|
||||
c = cmp.Compare(len(apiPattern), len(browserPattern))
|
||||
}
|
||||
switch {
|
||||
case c > 0:
|
||||
return apiHandler
|
||||
|
||||
@@ -241,18 +241,26 @@ func TestCSRFProtection(t *testing.T) {
|
||||
func TestContentSecurityPolicyHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csp CSP
|
||||
apiRoute bool
|
||||
wantCSP bool
|
||||
wantCSP string
|
||||
}{
|
||||
{
|
||||
name: "default routes get CSP headers",
|
||||
apiRoute: false,
|
||||
wantCSP: true,
|
||||
name: "default CSP",
|
||||
wantCSP: `base-uri 'self'; block-all-mixed-content; default-src 'self'; form-action 'self'; frame-ancestors 'none';`,
|
||||
},
|
||||
{
|
||||
name: "custom CSP",
|
||||
csp: CSP{
|
||||
"default-src": {"'self'", "https://tailscale.com"},
|
||||
"upgrade-insecure-requests": nil,
|
||||
},
|
||||
wantCSP: `default-src 'self' https://tailscale.com; upgrade-insecure-requests;`,
|
||||
},
|
||||
{
|
||||
name: "`/api/*` routes do not get CSP headers",
|
||||
apiRoute: true,
|
||||
wantCSP: false,
|
||||
wantCSP: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -265,9 +273,9 @@ func TestContentSecurityPolicyHeader(t *testing.T) {
|
||||
var s *Server
|
||||
var err error
|
||||
if tt.apiRoute {
|
||||
s, err = NewServer(Config{APIMux: h})
|
||||
s, err = NewServer(Config{APIMux: h, CSP: tt.csp})
|
||||
} else {
|
||||
s, err = NewServer(Config{BrowserMux: h})
|
||||
s, err = NewServer(Config{BrowserMux: h, CSP: tt.csp})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -279,8 +287,8 @@ func TestContentSecurityPolicyHeader(t *testing.T) {
|
||||
s.h.Handler.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
|
||||
if (resp.Header.Get("Content-Security-Policy") == "") == tt.wantCSP {
|
||||
t.Fatalf("content security policy want: %v; got: %v", tt.wantCSP, resp.Header.Get("Content-Security-Policy"))
|
||||
if got := resp.Header.Get("Content-Security-Policy"); got != tt.wantCSP {
|
||||
t.Fatalf("content security policy want: %q; got: %q", tt.wantCSP, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -397,7 +405,7 @@ func TestCSPAllowInlineStyles(t *testing.T) {
|
||||
csp := resp.Header.Get("Content-Security-Policy")
|
||||
allowsStyles := strings.Contains(csp, "style-src 'self' 'unsafe-inline'")
|
||||
if allowsStyles != allow {
|
||||
t.Fatalf("CSP inline styles want: %v; got: %v", allow, allowsStyles)
|
||||
t.Fatalf("CSP inline styles want: %v, got: %v in %q", allow, allowsStyles, csp)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -527,13 +535,13 @@ func TestGetMoreSpecificPattern(t *testing.T) {
|
||||
{
|
||||
desc: "same prefix",
|
||||
a: "/foo/bar/quux",
|
||||
b: "/foo/bar/",
|
||||
b: "/foo/bar/", // path.Clean will strip the trailing slash.
|
||||
want: apiHandler,
|
||||
},
|
||||
{
|
||||
desc: "almost same prefix, but not a path component",
|
||||
a: "/goat/sheep/cheese",
|
||||
b: "/goat/sheepcheese/",
|
||||
b: "/goat/sheepcheese/", // path.Clean will strip the trailing slash.
|
||||
want: apiHandler,
|
||||
},
|
||||
{
|
||||
@@ -554,6 +562,12 @@ func TestGetMoreSpecificPattern(t *testing.T) {
|
||||
b: "///////",
|
||||
want: unknownHandler,
|
||||
},
|
||||
{
|
||||
desc: "root-level",
|
||||
a: "/latest",
|
||||
b: "/", // path.Clean will NOT strip the trailing slash.
|
||||
want: apiHandler,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got := checkHandlerType(tt.a, tt.b)
|
||||
|
||||
@@ -224,7 +224,7 @@ main() {
|
||||
VERSION="leap/15.4"
|
||||
PACKAGETYPE="zypper"
|
||||
;;
|
||||
arch|archarm|endeavouros|blendos|garuda)
|
||||
arch|archarm|endeavouros|blendos|garuda|archcraft)
|
||||
OS="arch"
|
||||
VERSION="" # rolling release
|
||||
PACKAGETYPE="pacman"
|
||||
@@ -488,9 +488,41 @@ main() {
|
||||
set +x
|
||||
;;
|
||||
dnf)
|
||||
# DNF 5 has a different argument format; determine which one we have.
|
||||
DNF_VERSION="3"
|
||||
if dnf --version | grep -q '^dnf5 version'; then
|
||||
DNF_VERSION="5"
|
||||
fi
|
||||
|
||||
# The 'config-manager' plugin wasn't implemented when
|
||||
# DNF5 was released; detect that and use the old
|
||||
# version if necessary.
|
||||
if [ "$DNF_VERSION" = "5" ]; then
|
||||
set -x
|
||||
$SUDO dnf install -y 'dnf-command(config-manager)' && DNF_HAVE_CONFIG_MANAGER=1 || DNF_HAVE_CONFIG_MANAGER=0
|
||||
set +x
|
||||
|
||||
if [ "$DNF_HAVE_CONFIG_MANAGER" != "1" ]; then
|
||||
if type dnf-3 >/dev/null; then
|
||||
DNF_VERSION="3"
|
||||
else
|
||||
echo "dnf 5 detected, but 'dnf-command(config-manager)' not available and dnf-3 not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
set -x
|
||||
$SUDO dnf install -y 'dnf-command(config-manager)'
|
||||
$SUDO dnf config-manager --add-repo "https://pkgs.tailscale.com/$TRACK/$OS/$VERSION/tailscale.repo"
|
||||
if [ "$DNF_VERSION" = "3" ]; then
|
||||
$SUDO dnf install -y 'dnf-command(config-manager)'
|
||||
$SUDO dnf config-manager --add-repo "https://pkgs.tailscale.com/$TRACK/$OS/$VERSION/tailscale.repo"
|
||||
elif [ "$DNF_VERSION" = "5" ]; then
|
||||
# Already installed config-manager, above.
|
||||
$SUDO dnf config-manager addrepo --from-repofile="https://pkgs.tailscale.com/$TRACK/$OS/$VERSION/tailscale.repo"
|
||||
else
|
||||
echo "unexpected: unknown dnf version $DNF_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
$SUDO dnf install -y tailscale
|
||||
$SUDO systemctl enable --now tailscaled
|
||||
set +x
|
||||
|
||||
@@ -210,8 +210,6 @@ type incubatorArgs struct {
|
||||
debugTest bool
|
||||
isSELinuxEnforcing bool
|
||||
encodedEnv string
|
||||
allowListEnvKeys string
|
||||
forwardedEnviron []string
|
||||
}
|
||||
|
||||
func parseIncubatorArgs(args []string) (incubatorArgs, error) {
|
||||
@@ -246,31 +244,35 @@ func parseIncubatorArgs(args []string) (incubatorArgs, error) {
|
||||
ia.gids = append(ia.gids, gid)
|
||||
}
|
||||
|
||||
ia.forwardedEnviron = os.Environ()
|
||||
return ia, nil
|
||||
}
|
||||
|
||||
func (ia incubatorArgs) forwadedEnviron() ([]string, string, error) {
|
||||
environ := os.Environ()
|
||||
// pass through SSH_AUTH_SOCK environment variable to support ssh agent forwarding
|
||||
ia.allowListEnvKeys = "SSH_AUTH_SOCK"
|
||||
allowListKeys := "SSH_AUTH_SOCK"
|
||||
|
||||
if ia.encodedEnv != "" {
|
||||
unquoted, err := strconv.Unquote(ia.encodedEnv)
|
||||
if err != nil {
|
||||
return ia, fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||||
return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||||
}
|
||||
|
||||
var extraEnviron []string
|
||||
|
||||
err = json.Unmarshal([]byte(unquoted), &extraEnviron)
|
||||
if err != nil {
|
||||
return ia, fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||||
return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||||
}
|
||||
|
||||
ia.forwardedEnviron = append(ia.forwardedEnviron, extraEnviron...)
|
||||
environ = append(environ, extraEnviron...)
|
||||
|
||||
for _, v := range extraEnviron {
|
||||
ia.allowListEnvKeys = fmt.Sprintf("%s,%s", ia.allowListEnvKeys, strings.Split(v, "=")[0])
|
||||
allowListKeys = fmt.Sprintf("%s,%s", allowListKeys, strings.Split(v, "=")[0])
|
||||
}
|
||||
}
|
||||
|
||||
return ia, nil
|
||||
return environ, allowListKeys, nil
|
||||
}
|
||||
|
||||
// beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand.
|
||||
@@ -450,8 +452,13 @@ func tryExecLogin(dlogf logger.Logf, ia incubatorArgs) error {
|
||||
loginArgs := ia.loginArgs(loginCmdPath)
|
||||
dlogf("logging in with %+v", loginArgs)
|
||||
|
||||
environ, _, err := ia.forwadedEnviron()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If Exec works, the Go code will not proceed past this:
|
||||
err = unix.Exec(loginCmdPath, loginArgs, ia.forwardedEnviron)
|
||||
err = unix.Exec(loginCmdPath, loginArgs, environ)
|
||||
|
||||
// If we made it here, Exec failed.
|
||||
return err
|
||||
@@ -484,9 +491,14 @@ func trySU(dlogf logger.Logf, ia incubatorArgs) (handled bool, err error) {
|
||||
defer sessionCloser()
|
||||
}
|
||||
|
||||
environ, allowListEnvKeys, err := ia.forwadedEnviron()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
loginArgs := []string{
|
||||
su,
|
||||
"-w", ia.allowListEnvKeys,
|
||||
"-w", allowListEnvKeys,
|
||||
"-l",
|
||||
ia.localUser,
|
||||
}
|
||||
@@ -498,7 +510,7 @@ func trySU(dlogf logger.Logf, ia incubatorArgs) (handled bool, err error) {
|
||||
dlogf("logging in with %+v", loginArgs)
|
||||
|
||||
// If Exec works, the Go code will not proceed past this:
|
||||
err = unix.Exec(su, loginArgs, ia.forwardedEnviron)
|
||||
err = unix.Exec(su, loginArgs, environ)
|
||||
|
||||
// If we made it here, Exec failed.
|
||||
return true, err
|
||||
@@ -527,11 +539,16 @@ func findSU(dlogf logger.Logf, ia incubatorArgs) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
_, allowListEnvKeys, err := ia.forwadedEnviron()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// First try to execute su -w <allow listed env> -l <user> -c true
|
||||
// to make sure su supports the necessary arguments.
|
||||
err = exec.Command(
|
||||
su,
|
||||
"-w", ia.allowListEnvKeys,
|
||||
"-w", allowListEnvKeys,
|
||||
"-l",
|
||||
ia.localUser,
|
||||
"-c", "true",
|
||||
@@ -558,10 +575,15 @@ func handleSSHInProcess(dlogf logger.Logf, ia incubatorArgs) error {
|
||||
return err
|
||||
}
|
||||
|
||||
environ, _, err := ia.forwadedEnviron()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := shellArgs(ia.isShell, ia.cmd)
|
||||
dlogf("running %s %q", ia.loginShell, args)
|
||||
cmd := newCommand(ia.hasTTY, ia.loginShell, ia.forwardedEnviron, args)
|
||||
err := cmd.Run()
|
||||
cmd := newCommand(ia.hasTTY, ia.loginShell, environ, args)
|
||||
err = cmd.Run()
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
ps := ee.ProcessState
|
||||
code := ps.ExitCode()
|
||||
|
||||
@@ -149,7 +149,8 @@ type CapabilityVersion int
|
||||
// - 104: 2024-08-03: SelfNodeV6MasqAddrForThisPeer now works
|
||||
// - 105: 2024-08-05: Fixed SSH behavior on systems that use busybox (issue #12849)
|
||||
// - 106: 2024-09-03: fix panic regression from cryptokey routing change (65fe0ba7b5)
|
||||
const CurrentCapabilityVersion CapabilityVersion = 106
|
||||
// - 107: 2024-10-30: add App Connector to conffile (PR #13942)
|
||||
const CurrentCapabilityVersion CapabilityVersion = 107
|
||||
|
||||
type StableID string
|
||||
|
||||
@@ -651,6 +652,21 @@ func CheckTag(tag string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckServiceName validates svc for use as a service name.
|
||||
// We only allow valid DNS labels, since the expectation is that these will be
|
||||
// used as parts of domain names.
|
||||
func CheckServiceName(svc string) error {
|
||||
var ok bool
|
||||
svc, ok = strings.CutPrefix(svc, "svc:")
|
||||
if !ok {
|
||||
return errors.New("services must start with 'svc:'")
|
||||
}
|
||||
if svc == "" {
|
||||
return errors.New("service names must not be empty")
|
||||
}
|
||||
return dnsname.ValidLabel(svc)
|
||||
}
|
||||
|
||||
// CheckRequestTags checks that all of h.RequestTags are valid.
|
||||
func (h *Hostinfo) CheckRequestTags() error {
|
||||
if h == nil {
|
||||
@@ -771,7 +787,7 @@ type Hostinfo struct {
|
||||
// "5.10.0-17-amd64".
|
||||
OSVersion string `json:",omitempty"`
|
||||
|
||||
Container opt.Bool `json:",omitempty"` // whether the client is running in a container
|
||||
Container opt.Bool `json:",omitempty"` // best-effort whether the client is running in a container
|
||||
Env string `json:",omitempty"` // a hostinfo.EnvType in string form
|
||||
Distro string `json:",omitempty"` // "debian", "ubuntu", "nixos", ...
|
||||
DistroVersion string `json:",omitempty"` // "20.04", ...
|
||||
|
||||
@@ -35,7 +35,7 @@ func autoflagsForTest(argv []string, env *Environment, goroot, nativeGOOS, nativ
|
||||
cc = "cc"
|
||||
targetOS = cmp.Or(env.Get("GOOS", ""), nativeGOOS)
|
||||
targetArch = cmp.Or(env.Get("GOARCH", ""), nativeGOARCH)
|
||||
buildFlags = []string{"-trimpath"}
|
||||
buildFlags = []string{}
|
||||
cgoCflags = []string{"-O3", "-std=gnu11", "-g"}
|
||||
cgoLdflags []string
|
||||
ldflags []string
|
||||
@@ -47,6 +47,10 @@ func autoflagsForTest(argv []string, env *Environment, goroot, nativeGOOS, nativ
|
||||
subcommand = argv[1]
|
||||
}
|
||||
|
||||
if subcommand != "test" {
|
||||
buildFlags = append(buildFlags, "-trimpath")
|
||||
}
|
||||
|
||||
switch subcommand {
|
||||
case "build", "env", "install", "run", "test", "list":
|
||||
default:
|
||||
@@ -146,7 +150,11 @@ func autoflagsForTest(argv []string, env *Environment, goroot, nativeGOOS, nativ
|
||||
case env.IsSet("MACOSX_DEPLOYMENT_TARGET"):
|
||||
xcodeFlags = append(xcodeFlags, "-mmacosx-version-min="+env.Get("MACOSX_DEPLOYMENT_TARGET", ""))
|
||||
case env.IsSet("TVOS_DEPLOYMENT_TARGET"):
|
||||
xcodeFlags = append(xcodeFlags, "-mtvos-version-min="+env.Get("TVOS_DEPLOYMENT_TARGET", ""))
|
||||
if env.Get("TARGET_DEVICE_PLATFORM_NAME", "") == "appletvsimulator" {
|
||||
xcodeFlags = append(xcodeFlags, "-mtvos-simulator-version-min="+env.Get("TVOS_DEPLOYMENT_TARGET", ""))
|
||||
} else {
|
||||
xcodeFlags = append(xcodeFlags, "-mtvos-version-min="+env.Get("TVOS_DEPLOYMENT_TARGET", ""))
|
||||
}
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("invoked by Xcode but couldn't figure out deployment target. Did Xcode change its envvars again?")
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ GOTOOLCHAIN=local (was <nil>)
|
||||
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
|
||||
wantArgv: []string{
|
||||
"gocross", "test",
|
||||
"-trimpath",
|
||||
"-tags=tailscale_go,osusergo,netgo",
|
||||
"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'",
|
||||
"-race",
|
||||
|
||||
@@ -121,11 +121,17 @@ type Server struct {
|
||||
// field at zero unless you know what you are doing.
|
||||
Port uint16
|
||||
|
||||
// PreStart is an optional hook to run just before LocalBackend.Start,
|
||||
// to reconfigure internals. If it returns an error, Server.Start
|
||||
// will return that error, wrapper.
|
||||
PreStart func() error
|
||||
|
||||
getCertForTesting func(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
||||
|
||||
initOnce sync.Once
|
||||
initErr error
|
||||
lb *ipnlocal.LocalBackend
|
||||
sys *tsd.System
|
||||
netstack *netstack.Impl
|
||||
netMon *netmon.Monitor
|
||||
rootPath string // the state directory
|
||||
@@ -518,6 +524,7 @@ func (s *Server) start() (reterr error) {
|
||||
}
|
||||
|
||||
sys := new(tsd.System)
|
||||
s.sys = sys
|
||||
if err := s.startLogger(&closePool, sys.HealthTracker(), tsLogf); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -546,7 +553,7 @@ func (s *Server) start() (reterr error) {
|
||||
sys.HealthTracker().SetMetricsRegistry(sys.UserMetricsRegistry())
|
||||
|
||||
// TODO(oxtoacart): do we need to support Taildrive on tsnet, and if so, how?
|
||||
ns, err := netstack.Create(tsLogf, sys.Tun.Get(), eng, sys.MagicSock.Get(), s.dialer, sys.DNSManager.Get(), sys.ProxyMapper(), nil)
|
||||
ns, err := netstack.Create(tsLogf, sys.Tun.Get(), eng, sys.MagicSock.Get(), s.dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
||||
if err != nil {
|
||||
return fmt.Errorf("netstack.Create: %w", err)
|
||||
}
|
||||
@@ -614,6 +621,13 @@ func (s *Server) start() (reterr error) {
|
||||
prefs.ControlURL = s.ControlURL
|
||||
prefs.RunWebClient = s.RunWebClient
|
||||
authKey := s.getAuthKey()
|
||||
|
||||
if f := s.PreStart; f != nil {
|
||||
if err := f(); err != nil {
|
||||
return fmt.Errorf("PreStart: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = lb.Start(ipn.Options{
|
||||
UpdatePrefs: prefs,
|
||||
AuthKey: authKey,
|
||||
@@ -903,6 +917,7 @@ func (s *Server) APIClient() (*tailscale.Client, error) {
|
||||
}
|
||||
|
||||
c := tailscale.NewClient("-", nil)
|
||||
c.UserAgent = "tailscale-tsnet"
|
||||
c.HTTPClient = &http.Client{Transport: s.lb.KeyProvingNoiseRoundTripper()}
|
||||
return c, nil
|
||||
}
|
||||
@@ -1226,6 +1241,13 @@ func (s *Server) CapturePcap(ctx context.Context, pcapFile string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sys returns a handle to the Tailscale subsystems of this node.
|
||||
//
|
||||
// This is not a stable API, nor are the APIs of the returned subsystems.
|
||||
func (s *Server) Sys() *tsd.System {
|
||||
return s.sys
|
||||
}
|
||||
|
||||
type listenKey struct {
|
||||
network string
|
||||
host netip.Addr // or zero value for unspecified
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"golang.org/x/net/proxy"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/cmd/testwrapper/flakytest"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/ipn"
|
||||
@@ -874,6 +875,78 @@ func promMetricLabelsStr(labels []*dto.LabelPair) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sendData sends a given amount of bytes from s1 to s2.
|
||||
func sendData(logf func(format string, args ...any), ctx context.Context, bytesCount int, s1, s2 *Server, s1ip, s2ip netip.Addr) error {
|
||||
l := must.Get(s1.Listen("tcp", fmt.Sprintf("%s:8081", s1ip)))
|
||||
defer l.Close()
|
||||
|
||||
// Dial to s1 from s2
|
||||
w, err := s2.Dial(ctx, "tcp", fmt.Sprintf("%s:8081", s1ip))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
stopReceive := make(chan struct{})
|
||||
defer close(stopReceive)
|
||||
allReceived := make(chan error)
|
||||
defer close(allReceived)
|
||||
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
allReceived <- err
|
||||
return
|
||||
}
|
||||
conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
total := 0
|
||||
recvStart := time.Now()
|
||||
for {
|
||||
got := make([]byte, bytesCount)
|
||||
n, err := conn.Read(got)
|
||||
if n != bytesCount {
|
||||
logf("read %d bytes, want %d", n, bytesCount)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-stopReceive:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
allReceived <- fmt.Errorf("failed reading packet, %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
total += n
|
||||
logf("received %d/%d bytes, %.2f %%", total, bytesCount, (float64(total) / (float64(bytesCount)) * 100))
|
||||
if total == bytesCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
logf("all received, took: %s", time.Since(recvStart).String())
|
||||
allReceived <- nil
|
||||
}()
|
||||
|
||||
sendStart := time.Now()
|
||||
w.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
if _, err := w.Write(bytes.Repeat([]byte("A"), bytesCount)); err != nil {
|
||||
stopReceive <- struct{}{}
|
||||
return err
|
||||
}
|
||||
|
||||
logf("all sent (%s), waiting for all packets (%d) to be received", time.Since(sendStart).String(), bytesCount)
|
||||
err, _ = <-allReceived
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUserMetrics(t *testing.T) {
|
||||
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/13420")
|
||||
tstest.ResourceCheck(t)
|
||||
@@ -882,7 +955,7 @@ func TestUserMetrics(t *testing.T) {
|
||||
|
||||
controlURL, c := startControl(t)
|
||||
s1, s1ip, s1PubKey := startServer(t, ctx, controlURL, "s1")
|
||||
s2, _, _ := startServer(t, ctx, controlURL, "s2")
|
||||
s2, s2ip, _ := startServer(t, ctx, controlURL, "s2")
|
||||
|
||||
s1.lb.EditPrefs(&ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
@@ -951,6 +1024,20 @@ func TestUserMetrics(t *testing.T) {
|
||||
return status1.Self.PrimaryRoutes != nil && status1.Self.PrimaryRoutes.Len() == int(wantRoutes)+1
|
||||
})
|
||||
|
||||
mustDirect(t, t.Logf, lc1, lc2)
|
||||
|
||||
// 10 megabytes
|
||||
bytesToSend := 10 * 1024 * 1024
|
||||
|
||||
// This asserts generates some traffic, it is factored out
|
||||
// of TestUDPConn.
|
||||
start := time.Now()
|
||||
err = sendData(t.Logf, ctx, bytesToSend, s1, s2, s1ip, s2ip)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send packets: %v", err)
|
||||
}
|
||||
t.Logf("Sent %d bytes from s1 to s2 in %s", bytesToSend, time.Since(start).String())
|
||||
|
||||
ctxLc, cancelLc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancelLc()
|
||||
metrics1, err := lc1.UserMetrics(ctxLc)
|
||||
@@ -968,6 +1055,9 @@ func TestUserMetrics(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Allow the metrics for the bytes sent to be off by 15%.
|
||||
bytesSentTolerance := 1.15
|
||||
|
||||
t.Logf("Metrics1:\n%s\n", metrics1)
|
||||
|
||||
// The node is advertising 4 routes:
|
||||
@@ -990,11 +1080,16 @@ func TestUserMetrics(t *testing.T) {
|
||||
t.Errorf("metrics1, tailscaled_health_messages: got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// The node is the primary subnet router for 2 routes:
|
||||
// - 192.0.2.0/24
|
||||
// - 192.0.5.1/32
|
||||
if got, want := parsedMetrics1["tailscaled_primary_routes"], wantRoutes; got != want {
|
||||
t.Errorf("metrics1, tailscaled_primary_routes: got %v, want %v", got, want)
|
||||
// Verify that the amount of data recorded in bytes is higher or equal to the
|
||||
// 10 megabytes sent.
|
||||
inboundBytes1 := parsedMetrics1[`tailscaled_inbound_bytes_total{path="direct_ipv4"}`]
|
||||
if inboundBytes1 < float64(bytesToSend) {
|
||||
t.Errorf(`metrics1, tailscaled_inbound_bytes_total{path="direct_ipv4"}: expected higher (or equal) than %d, got: %f`, bytesToSend, inboundBytes1)
|
||||
}
|
||||
|
||||
// But ensure that it is not too much higher than the 10 megabytes sent.
|
||||
if inboundBytes1 > float64(bytesToSend)*bytesSentTolerance {
|
||||
t.Errorf(`metrics1, tailscaled_inbound_bytes_total{path="direct_ipv4"}: expected lower than %f, got: %f`, float64(bytesToSend)*bytesSentTolerance, inboundBytes1)
|
||||
}
|
||||
|
||||
metrics2, err := lc2.UserMetrics(ctx)
|
||||
@@ -1029,9 +1124,16 @@ func TestUserMetrics(t *testing.T) {
|
||||
t.Errorf("metrics2, tailscaled_health_messages: got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// The node is the primary subnet router for 0 routes
|
||||
if got, want := parsedMetrics2["tailscaled_primary_routes"], 0.0; got != want {
|
||||
t.Errorf("metrics2, tailscaled_primary_routes: got %v, want %v", got, want)
|
||||
// Verify that the amount of data recorded in bytes is higher or equal than the
|
||||
// 10 megabytes sent.
|
||||
outboundBytes2 := parsedMetrics2[`tailscaled_outbound_bytes_total{path="direct_ipv4"}`]
|
||||
if outboundBytes2 < float64(bytesToSend) {
|
||||
t.Errorf(`metrics2, tailscaled_outbound_bytes_total{path="direct_ipv4"}: expected higher (or equal) than %d, got: %f`, bytesToSend, outboundBytes2)
|
||||
}
|
||||
|
||||
// But ensure that it is not too much higher than the 10 megabytes sent.
|
||||
if outboundBytes2 > float64(bytesToSend)*bytesSentTolerance {
|
||||
t.Errorf(`metrics2, tailscaled_outbound_bytes_total{path="direct_ipv4"}: expected lower than %f, got: %f`, float64(bytesToSend)*bytesSentTolerance, outboundBytes2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1044,3 +1146,33 @@ func waitForCondition(t *testing.T, msg string, waitTime time.Duration, f func()
|
||||
}
|
||||
t.Fatalf("waiting for condition: %s", msg)
|
||||
}
|
||||
|
||||
// mustDirect ensures there is a direct connection between LocalClient 1 and 2
|
||||
func mustDirect(t *testing.T, logf logger.Logf, lc1, lc2 *tailscale.LocalClient) {
|
||||
t.Helper()
|
||||
lastLog := time.Now().Add(-time.Minute)
|
||||
// See https://github.com/tailscale/tailscale/issues/654
|
||||
// and https://github.com/tailscale/tailscale/issues/3247 for discussions of this deadline.
|
||||
for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
status1, err := lc1.Status(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
status2, err := lc2.Status(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pst := status1.Peer[status2.Self.PublicKey]
|
||||
if pst.CurAddr != "" {
|
||||
logf("direct link %s->%s found with addr %s", status1.Self.HostName, status2.Self.HostName, pst.CurAddr)
|
||||
return
|
||||
}
|
||||
if now := time.Now(); now.Sub(lastLog) > time.Second {
|
||||
logf("no direct path %s->%s yet, addrs %v", status1.Self.HostName, status2.Self.HostName, pst.Addrs)
|
||||
lastLog = now
|
||||
}
|
||||
}
|
||||
t.Error("magicsock did not find a direct path from lc1 to lc2")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user