Compare commits

...

34 Commits

Author SHA1 Message Date
Andrew Dunham
f3db001121 util/execqueue: add metrics
Expose enough metrics to get a sense of queue depth, use and if it has
stalled.

Updates tailscale/corp#26058

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I271ac8d03f3db587a33aca6964fe92f2833e1251
2025-01-24 13:17:19 -08:00
Tom Proctor
69bc164c62 ipn/ipnlocal: include DNS SAN in cert CSR (#14764)
The CN field is technically deprecated; set the requested name in a DNS SAN
extension in addition to maximise compatibility with RFC 8555.

Fixes #14762

Change-Id: If5d27f1e7abc519ec86489bf034ac98b2e613043

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-01-24 17:04:26 +00:00
Adrian Dewhurst
d69c70ee5b tailcfg: adjust ServiceName.Validate to use vizerror
Updates #cleanup

Change-Id: I163b3f762b9d45c2155afe1c0a36860606833a22
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2025-01-24 10:57:46 -05:00
Kristoffer Dalby
05afa31df3 util/clientmetric: use counter in aggcounter
Fixes #14743

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-24 15:17:44 +01:00
Percy Wegmann
450bc9a6b8 cmd/derper,derp: make TCP write timeout configurable
The timeout still defaults to 2 seconds, but can now be changed via command-line flag.

Updates tailscale/corp#26045

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-01-24 07:50:52 -06:00
Percy Wegmann
5e9056a356 derp: move Conn interface to derp.go
This interface is used both by the DERP client as well as the server.
Defining the interface in derp.go makes it clear that it is shared.

Updates tailscale/corp#26045

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-01-24 07:50:52 -06:00
Kristoffer Dalby
f0b63d0eec wgengine/filter: add check for unknown proto
Updates #14280

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-24 12:20:44 +01:00
Kristoffer Dalby
f39ee8e520 net/tstun: add back outgoing drop metric
Using new labels returned from the filter

Updates #14280

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-24 12:20:44 +01:00
Kristoffer Dalby
5756bc1704 wgengine/filter: return drop reason for metrics
Updates #14280

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-24 12:20:44 +01:00
Kristoffer Dalby
3a39f08735 util/usermetric: add more drop labels
Updates #14280

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-24 12:20:44 +01:00
Brad Fitzpatrick
61bea75092 cmd/tailscale: fix, test some recent doc inconsistencies
3dabea0fc2 added some docs with inconsistent usage docs.
This fixes them, and adds a test.

It also adds some other tests and fixes other verb tense
inconsistencies.

Updates tailscale/corp#25278

Change-Id: I94c2a8940791bddd7c35c1c3d5fb791a317370c2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-23 18:51:16 -08:00
Nick Khyl
f0db47338e cmd/tailscaled,util/syspolicy/source,util/winutil/gp: disallow acquiring the GP lock during service startup
In v1.78, we started acquiring the GP lock when reading policy settings. This led to a deadlock during
Tailscale installation via Group Policy Software Installation because the GP engine holds the write lock
for the duration of policy processing, which in turn waits for the installation to complete, which in turn
waits for the service to enter the running state.

In this PR, we prevent the acquisition of GP locks (aka EnterCriticalPolicySection) during service startup
and update the Windows Registry-based util/syspolicy/source.PlatformPolicyStore to handle this failure
gracefully. The GP lock is somewhat optional; it’s safe to read policy settings without it, but acquiring
the lock is recommended when reading multiple values to prevent the Group Policy engine from modifying
settings mid-read and to avoid inconsistent results.

Fixes #14416

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-01-23 15:06:47 -06:00
Brad Fitzpatrick
413fb5b933 control/controlclient: delete unreferenced mapSession UserProfiles
This was a slow memory leak on busy tailnets with lots of tagged
ephemeral nodes.

Updates tailscale/corp#26058

Change-Id: I298e7d438e3ffbb3cde795640e344671d244c632
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-23 12:58:06 -08:00
Brad Fitzpatrick
d6abbc2e61 net/tstun: move TAP support out to separate package feature/tap
Still behind the same ts_omit_tap build tag.

See #14738 for background on the pattern.

Updates #12614

Change-Id: I03fb3d2bf137111e727415bd8e713d8568156ecc
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-23 11:00:49 -08:00
Andrew Lytvynov
f1710f4a42 appc,ipn/ipnlocal: log DNS parsing errors in app connectors (#14607)
If we fail to parse the upstream DNS response in an app connector, we
might miss new IPs for the target domain. Log parsing errors to be able
to diagnose that.

Updates #14606

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2025-01-23 09:03:56 -08:00
Mike O'Driscoll
a00623e8c4 derp,wgengine/magicsock: remove unexpected label (#14711)
Remove "unexpected" labelling of PeerGoneReasonNotHere.
A peer being no longer connected to a DERP server
is not an unexpected case and causes confusion in looking at logs.

Fixes tailscale/corp#25609

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2025-01-23 09:04:03 -05:00
Tom Proctor
3033a96b02 cmd/k8s-operator: fix reconciler name clash (#14712)
The new ProxyGroup-based Ingress reconciler is causing a fatal log at
startup because it has the same name as the existing Ingress reconciler.
Explicitly name both to ensure they have unique names that are consistent
with other explicitly named reconcilers.

Updates #14583

Change-Id: Ie76e3eaf3a96b1cec3d3615ea254a847447372ea
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-01-23 10:47:21 +00:00
Brad Fitzpatrick
1562a6f2f2 feature/*: make Wake-on-LAN conditional, start supporting modular features
This pulls out the Wake-on-LAN (WoL) code out into its own package
(feature/wakeonlan) that registers itself with various new hooks
around tailscaled.

Then a new build tag (ts_omit_wakeonlan) causes the package to not
even be linked in the binary.

Ohter new packages include:

   * feature: to just record which features are loaded. Future:
     dependencies between features.
   * feature/condregister: the package with all the build tags
     that tailscaled, tsnet, and the Tailscale Xcode project
     extension can empty (underscore) import to load features
     as a function of the defined build tags.

Future commits will move of our "ts_omit_foo" build tags into this
style.

Updates #12614

Change-Id: I9c5378dafb1113b62b816aabef02714db3fc9c4a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-22 17:16:15 -08:00
Andrew Lytvynov
3fb8a1f6bf ipn/ipnlocal: re-advertise appc routes on startup, take 2 (#14740)
* Reapply "ipn/ipnlocal: re-advertise appc routes on startup (#14609)"

This reverts commit 51adaec35a.

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>

* ipn/ipnlocal: fix a deadlock in readvertiseAppConnectorRoutes

Don't hold LocalBackend.mu while calling the methods of
appc.AppConnector. Those methods could call back into LocalBackend and
try to acquire it's mutex.

Fixes https://github.com/tailscale/corp/issues/25965
Fixes #14606

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>

---------

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2025-01-22 16:50:25 -08:00
Andrea Gottardo
3dabea0fc2 cmd/tailscale: define CLI tools to manipulate macOS network and system extensions (#14727)
Updates tailscale/corp#25278

Adds definitions for new CLI commands getting added in v1.80. Refactors some pre-existing CLI commands within the `configure` tree to clean up code.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2025-01-22 16:01:07 -08:00
Adrian Dewhurst
0fa7b4a236 tailcfg: add ServiceName
Rather than using a string everywhere and needing to clarify that the
string should have the svc: prefix, create a separate type for Service
names.

Updates tailscale/corp#24607

Change-Id: I720e022f61a7221644bb60955b72cacf42f59960
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2025-01-22 15:27:46 -05:00
dependabot[bot]
d1b378504c .github: Bump slackapi/slack-github-action from 1.27.0 to 2.0.0 (#14141)
Bumps [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) from 1.27.0 to 2.0.0.
- [Release notes](https://github.com/slackapi/slack-github-action/releases)
- [Commits](37ebaef184...485a9d42d3)

---
updated-dependencies:
- dependency-name: slackapi/slack-github-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-22 11:46:13 -07:00
Brad Fitzpatrick
8b65598614 util/slicesx: add AppendNonzero
By request of @agottardo.

Updates #cleanup

Change-Id: I2f02314eb9533b1581e47b66b45b6fb8ac257bb7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-22 10:20:56 -08:00
Brad Fitzpatrick
17022ad0e9 tailcfg: remove now-unused TailscaleFunnelEnabled method
As of tailscale/corp#26003

Updates tailscale/tailscale#11572

Change-Id: I5de2a0951b7b8972744178abc1b0e7948087d412
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-22 09:37:24 -08:00
KevinLiang10
e4779146b5 delete extra struct in tailcfg
Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>
2025-01-22 11:02:26 -05:00
KevinLiang10
550923d953 fix handler related and some nit
Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>
2025-01-22 11:02:26 -05:00
KevinLiang10
0a57051f2e add blank line
Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>
2025-01-22 11:02:26 -05:00
KevinLiang10
ccd1643043 add copyright header
Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>
2025-01-22 11:02:26 -05:00
KevinLiang10
8c8750f1b3 ipn/ipnlocal: Support TCP and Web VIP services
This commit intend to provide support for TCP and Web VIP services and also allow user to use Tun
for VIP services if they want to.
The commit includes:
1.Setting TCP intercept function for VIP Services.
2.Update netstack to send packet written from WG to netStack handler for VIP service.
3.Return correct TCP hander for VIP services when netstack acceptTCP.

This commit also includes unit tests for if the local backend setServeConfig would set correct TCP intercept
function and test if a hander gets returned when getting TCPHandlerForDst. The shouldProcessInbound
check is not unit tested since the test result just depends on mocked functions. There should be an integration
test to cover  shouldProcessInbound and if the returned TCP handler actually does what the serveConfig says.

Updates tailscale/corp#24604

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>
2025-01-22 11:02:26 -05:00
Brad Fitzpatrick
cb3b1a1dcf tsweb: add missing debug pprof endpoints
Updates tailscale/corp#26016

Change-Id: I47a5671e881cc092d83c1e992e2271f90afcae7e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-22 06:34:59 -08:00
Brad Fitzpatrick
042ed6bf69 net/bakedroots: add LetsEncrypt ISRG Root X2
Updates #14690

Change-Id: Ib85e318d48450fc6534f7b0c1d4cc4335de7c0ff
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-21 17:47:55 -08:00
Brad Fitzpatrick
150cd30b1d ipn/ipnlocal: also use LetsEncrypt-baked-in roots for cert validation
We previously baked in the LetsEncrypt x509 root CA for our tlsdial
package.

This moves that out into a new "bakedroots" package and is now also
shared by ipn/ipnlocal's cert validation code (validCertPEM) that
decides whether it's time to fetch a new cert.

Otherwise, a machine without LetsEncrypt roots locally in its system
roots is unable to use tailscale cert/serve and fetch certs.

Fixes #14690

Change-Id: Ic88b3bdaabe25d56b9ff07ada56a27e3f11d7159
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-21 17:47:55 -08:00
Brad Fitzpatrick
e12b2a7267 cmd/tailscale/cli: clean up how optional commands get registered
Both @agottardo and I tripped over this today.

Updates #cleanup

Change-Id: I64380a03bfc952b9887b1512dbcadf26499ff1cd
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-01-21 15:57:14 -08:00
James Tucker
8b9d5fd6bc go.mod: bump github.com/inetaf/tcpproxy
Updates tailscale/corp#25169

Signed-off-by: James Tucker <james@tailscale.com>
2025-01-21 11:26:44 -08:00
83 changed files with 2305 additions and 682 deletions

View File

@@ -24,13 +24,13 @@ jobs:
- name: Post to slack
if: failure() && github.event_name == 'schedule'
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
env:
SLACK_BOT_TOKEN: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
with:
channel-id: 'C05PXRM304B'
method: chat.postMessage
token: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
payload: |
{
"channel": "C05PXRM304B",
"blocks": [
{
"type": "section",

View File

@@ -569,8 +569,10 @@ jobs:
# By having the job always run, but skipping its only step as needed, we
# let the CI output collapse nicely in PRs.
if: failure() && github.event_name == 'push'
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"attachments": [{
@@ -582,9 +584,6 @@ jobs:
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
check_mergeability:
if: always()

View File

@@ -374,13 +374,13 @@ func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
// response is being returned over the PeerAPI. The response is parsed and
// matched against the configured domains, if matched the routeAdvertiser is
// advised to advertise the discovered route.
func (e *AppConnector) ObserveDNSResponse(res []byte) {
func (e *AppConnector) ObserveDNSResponse(res []byte) error {
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return
return err
}
if err := p.SkipAllQuestions(); err != nil {
return
return err
}
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
@@ -399,12 +399,12 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
break
}
if err != nil {
return
return err
}
if h.Class != dnsmessage.ClassINET {
if err := p.SkipAnswer(); err != nil {
return
return err
}
continue
}
@@ -413,7 +413,7 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
default:
if err := p.SkipAnswer(); err != nil {
return
return err
}
continue
@@ -427,7 +427,7 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
if h.Type == dnsmessage.TypeCNAME {
res, err := p.CNAMEResource()
if err != nil {
return
return err
}
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
if len(cname) == 0 {
@@ -441,20 +441,20 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return
return err
}
addr := netip.AddrFrom4(r.A)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return
return err
}
addr := netip.AddrFrom16(r.AAAA)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
default:
if err := p.SkipAnswer(); err != nil {
return
return err
}
continue
}
@@ -485,6 +485,7 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
e.scheduleAdvertisement(domain, toAdvertise...)
}
}
return nil
}
// starting from the given domain that resolved to an address, find it, or any

View File

@@ -69,7 +69,9 @@ func TestUpdateRoutes(t *testing.T) {
a.updateDomains([]string{"*.example.com"})
// This route should be collapsed into the range
a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1"))
if err := a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(ctx)
if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) {
@@ -77,7 +79,9 @@ func TestUpdateRoutes(t *testing.T) {
}
// This route should not be collapsed or removed
a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1"))
if err := a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(ctx)
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
@@ -130,7 +134,9 @@ func TestDomainRoutes(t *testing.T) {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
a.updateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(context.Background())
want := map[string][]netip.Addr{
@@ -155,7 +161,9 @@ func TestObserveDNSResponse(t *testing.T) {
}
// a has no domains configured, so it should not advertise any routes
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
if got, want := rc.Routes(), ([]netip.Prefix)(nil); !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
@@ -163,7 +171,9 @@ func TestObserveDNSResponse(t *testing.T) {
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
a.updateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(ctx)
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
@@ -172,7 +182,9 @@ func TestObserveDNSResponse(t *testing.T) {
// a CNAME record chain should result in a route being added if the chain
// matches a routed domain.
a.updateDomains([]string{"www.example.com", "example.com"})
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com."))
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com.")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(ctx)
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32"))
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
@@ -181,7 +193,9 @@ func TestObserveDNSResponse(t *testing.T) {
// a CNAME record chain should result in a route being added if the chain
// even if only found in the middle of the chain
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org."))
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org.")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(ctx)
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32"))
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
@@ -190,14 +204,18 @@ func TestObserveDNSResponse(t *testing.T) {
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(ctx)
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// don't re-advertise routes that have already been advertised
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(ctx)
if !slices.Equal(rc.Routes(), wantRoutes) {
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
@@ -207,7 +225,9 @@ func TestObserveDNSResponse(t *testing.T) {
pfx := netip.MustParsePrefix("192.0.2.0/24")
a.updateRoutes([]netip.Prefix{pfx})
wantRoutes = append(wantRoutes, pfx)
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1"))
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(ctx)
if !slices.Equal(rc.Routes(), wantRoutes) {
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
@@ -230,7 +250,9 @@ func TestWildcardDomains(t *testing.T) {
}
a.updateDomains([]string{"*.example.com"})
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
if err := a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.Wait(ctx)
if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
t.Errorf("routes: got %v; want %v", got, want)
@@ -438,10 +460,16 @@ func TestUpdateDomainRouteRemoval(t *testing.T) {
// adding domains doesn't immediately cause any routes to be advertised
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.3"))
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.4"))
for _, res := range [][]byte{
dnsResponse("a.example.com.", "1.2.3.1"),
dnsResponse("a.example.com.", "1.2.3.2"),
dnsResponse("b.example.com.", "1.2.3.3"),
dnsResponse("b.example.com.", "1.2.3.4"),
} {
if err := a.ObserveDNSResponse(res); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
}
a.Wait(ctx)
// observing dns responses causes routes to be advertised
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
@@ -487,10 +515,16 @@ func TestUpdateWildcardRouteRemoval(t *testing.T) {
// adding domains doesn't immediately cause any routes to be advertised
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
a.ObserveDNSResponse(dnsResponse("1.b.example.com.", "1.2.3.3"))
a.ObserveDNSResponse(dnsResponse("2.b.example.com.", "1.2.3.4"))
for _, res := range [][]byte{
dnsResponse("a.example.com.", "1.2.3.1"),
dnsResponse("a.example.com.", "1.2.3.2"),
dnsResponse("1.b.example.com.", "1.2.3.3"),
dnsResponse("2.b.example.com.", "1.2.3.4"),
} {
if err := a.ObserveDNSResponse(res); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
}
a.Wait(ctx)
// observing dns responses causes routes to be advertised
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})

View File

@@ -37,7 +37,7 @@ while [ "$#" -gt 1 ]; do
--extra-small)
shift
ldflags="$ldflags -w -s"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan"
;;
--box)
shift

View File

@@ -99,6 +99,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/metrics from tailscale.com/cmd/derper+
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
tailscale.com/net/netaddr from tailscale.com/ipn+

View File

@@ -77,6 +77,8 @@ var (
tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time")
// tcpUserTimeout is intentionally short, so that hung connections are cleaned up promptly. DERPs should be nearby users.
tcpUserTimeout = flag.Duration("tcp-user-timeout", 15*time.Second, "TCP user timeout")
// tcpWriteTimeout is the timeout for writing to client TCP connections. It does not apply to mesh connections.
tcpWriteTimeout = flag.Duration("tcp-write-timeout", derp.DefaultTCPWiteTimeout, "TCP write timeout; 0 results in no timeout being set on writes")
)
var (
@@ -173,6 +175,7 @@ func main() {
s.SetVerifyClient(*verifyClients)
s.SetVerifyClientURL(*verifyClientURL)
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
s.SetTCPWriteTimeout(*tcpWriteTimeout)
if *meshPSKFile != "" {
b, err := os.ReadFile(*meshPSKFile)

View File

@@ -140,7 +140,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify/v2 from tailscale.com/net/dns
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/feature/tap
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4
@@ -156,7 +156,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd
github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe
github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd
github.com/kortschak/wol from tailscale.com/ipn/ipnlocal
github.com/kortschak/wol from tailscale.com/feature/wakeonlan
github.com/mailru/easyjson/buffer from github.com/mailru/easyjson/jwriter
💣 github.com/mailru/easyjson/jlexer from github.com/go-openapi/swag
github.com/mailru/easyjson/jwriter from github.com/go-openapi/swag
@@ -302,7 +302,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
gvisor.dev/gvisor/pkg/tcpip/network/internal/fragmentation from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
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/ipv4 from tailscale.com/feature/tap+
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+
@@ -801,6 +801,10 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/feature from tailscale.com/feature/wakeonlan+
tailscale.com/feature/condregister from tailscale.com/tsnet
L tailscale.com/feature/tap from tailscale.com/feature/condregister
tailscale.com/feature/wakeonlan from tailscale.com/feature/condregister
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+
@@ -835,6 +839,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/logtail/backoff from tailscale.com/control/controlclient+
tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+
tailscale.com/metrics from tailscale.com/derp+
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
tailscale.com/net/connstats from tailscale.com/net/tstun+
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+

View File

@@ -222,13 +222,14 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
},
},
}
serviceName := tailcfg.ServiceName("svc:" + hostname)
var gotCfg *ipn.ServiceConfig
if cfg != nil && cfg.Services != nil {
gotCfg = cfg.Services[hostname]
gotCfg = cfg.Services[serviceName]
}
if !reflect.DeepEqual(gotCfg, ingCfg) {
logger.Infof("Updating serve config")
mak.Set(&cfg.Services, hostname, ingCfg)
mak.Set(&cfg.Services, serviceName, ingCfg)
cfgBytes, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("error marshaling serve config: %w", err)
@@ -309,7 +310,7 @@ func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
found := false
for _, i := range ingList.Items {
ingressHostname := hostnameForIngress(&i)
if ingressHostname == vipHostname {
if ingressHostname == vipHostname.WithoutPrefix() {
found = true
break
}
@@ -317,7 +318,7 @@ func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
if !found {
logger.Infof("VIPService %q is not owned by any Ingress, cleaning up", vipHostname)
svc, err := a.getVIPService(ctx, vipHostname, logger)
svc, err := a.getVIPService(ctx, vipHostname.WithoutPrefix(), logger)
if err != nil {
errResp := &tailscale.ErrResponse{}
if errors.As(err, &errResp) && errResp.Status == http.StatusNotFound {
@@ -329,7 +330,7 @@ func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
}
if isVIPServiceForAnyIngress(svc) {
logger.Infof("cleaning up orphaned VIPService %q", vipHostname)
if err := a.tsClient.deleteVIPServiceByName(ctx, vipHostname); err != nil {
if err := a.tsClient.deleteVIPServiceByName(ctx, vipHostname.WithoutPrefix()); err != nil {
errResp := &tailscale.ErrResponse{}
if !errors.As(err, &errResp) || errResp.Status != http.StatusNotFound {
return fmt.Errorf("deleting VIPService %q: %w", vipHostname, err)
@@ -374,11 +375,12 @@ func (a *IngressPGReconciler) maybeCleanup(ctx context.Context, hostname string,
if err != nil {
return fmt.Errorf("error getting ProxyGroup serve config: %w", err)
}
serviceName := tailcfg.ServiceName("svc:" + hostname)
// VIPService is always first added to serve config and only then created in the Tailscale API, so if it is not
// found in the serve config, we can assume that there is no VIPService. TODO(irbekrm): once we have ingress
// ProxyGroup, we will probably add currently exposed VIPServices to its status. At that point, we can use the
// status rather than checking the serve config each time.
if cfg == nil || cfg.Services == nil || cfg.Services[hostname] == nil {
if cfg == nil || cfg.Services == nil || cfg.Services[serviceName] == nil {
return nil
}
logger.Infof("Ensuring that VIPService %q configuration is cleaned up", hostname)
@@ -390,7 +392,7 @@ func (a *IngressPGReconciler) maybeCleanup(ctx context.Context, hostname string,
// 3. Remove the VIPService from the serve config for the ProxyGroup.
logger.Infof("Removing VIPService %q from serve config for ProxyGroup %q", hostname, pg)
delete(cfg.Services, hostname)
delete(cfg.Services, serviceName)
cfgBytes, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("error marshaling serve config: %w", err)

View File

@@ -137,7 +137,7 @@ func TestIngressPGReconciler(t *testing.T) {
t.Fatalf("unmarshaling serve config: %v", err)
}
if cfg.Services["my-svc"] == nil {
if cfg.Services["svc:my-svc"] == nil {
t.Error("expected serve config to contain VIPService configuration")
}

View File

@@ -315,6 +315,7 @@ func runReconcilers(opts reconcilerOpts) {
err = builder.
ControllerManagedBy(mgr).
For(&networkingv1.Ingress{}).
Named("ingress-reconciler").
Watches(&appsv1.StatefulSet{}, ingressChildFilter).
Watches(&corev1.Secret{}, ingressChildFilter).
Watches(&corev1.Service{}, svcHandlerForIngress).
@@ -336,6 +337,7 @@ func runReconcilers(opts reconcilerOpts) {
err = builder.
ControllerManagedBy(mgr).
For(&networkingv1.Ingress{}).
Named("ingress-pg-reconciler").
Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngressPG(mgr.GetClient(), startlog))).
Complete(&IngressPGReconciler{
recorder: eventRecorder,
@@ -357,6 +359,7 @@ func runReconcilers(opts reconcilerOpts) {
proxyClassFilterForConnector := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForConnector(mgr.GetClient(), startlog))
err = builder.ControllerManagedBy(mgr).
For(&tsapi.Connector{}).
Named("connector-reconciler").
Watches(&appsv1.StatefulSet{}, connectorFilter).
Watches(&corev1.Secret{}, connectorFilter).
Watches(&tsapi.ProxyClass{}, proxyClassFilterForConnector).
@@ -376,6 +379,7 @@ func runReconcilers(opts reconcilerOpts) {
nameserverFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("nameserver"))
err = builder.ControllerManagedBy(mgr).
For(&tsapi.DNSConfig{}).
Named("nameserver-reconciler").
Watches(&appsv1.Deployment{}, nameserverFilter).
Watches(&corev1.ConfigMap{}, nameserverFilter).
Watches(&corev1.Service{}, nameserverFilter).
@@ -455,6 +459,7 @@ func runReconcilers(opts reconcilerOpts) {
serviceMonitorFilter := handler.EnqueueRequestsFromMapFunc(proxyClassesWithServiceMonitor(mgr.GetClient(), opts.log))
err = builder.ControllerManagedBy(mgr).
For(&tsapi.ProxyClass{}).
Named("proxyclass-reconciler").
Watches(&apiextensionsv1.CustomResourceDefinition{}, serviceMonitorFilter).
Complete(&ProxyClassReconciler{
Client: mgr.GetClient(),
@@ -498,6 +503,7 @@ func runReconcilers(opts reconcilerOpts) {
recorderFilter := handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &tsapi.Recorder{})
err = builder.ControllerManagedBy(mgr).
For(&tsapi.Recorder{}).
Named("recorder-reconciler").
Watches(&appsv1.StatefulSet{}, recorderFilter).
Watches(&corev1.ServiceAccount{}, recorderFilter).
Watches(&corev1.Secret{}, recorderFilter).
@@ -520,6 +526,7 @@ func runReconcilers(opts reconcilerOpts) {
proxyClassFilterForProxyGroup := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForProxyGroup(mgr.GetClient(), startlog))
err = builder.ControllerManagedBy(mgr).
For(&tsapi.ProxyGroup{}).
Named("proxygroup-reconciler").
Watches(&appsv1.StatefulSet{}, ownedByProxyGroupFilter).
Watches(&corev1.ConfigMap{}, ownedByProxyGroupFilter).
Watches(&corev1.ServiceAccount{}, ownedByProxyGroupFilter).

View File

@@ -21,23 +21,21 @@ var advertiseArgs struct {
// 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 {
func advertiseCmd() *ffcli.Command {
if !envknob.UseWIPCode() {
return nil
}
return []*ffcli.Command{advertiseCmd}
return &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 runAdvertise(ctx context.Context, args []string) error {
@@ -68,7 +66,7 @@ func parseServiceNames(servicesArg string) ([]string, error) {
if servicesArg != "" {
services = strings.Split(servicesArg, ",")
for _, svc := range services {
err := tailcfg.CheckServiceName(svc)
err := tailcfg.ServiceName(svc).Validate()
if err != nil {
return nil, fmt.Errorf("service %q: %s", svc, err)
}

View File

@@ -25,6 +25,7 @@ import (
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/envknob"
"tailscale.com/paths"
"tailscale.com/util/slicesx"
"tailscale.com/version/distro"
)
@@ -182,14 +183,14 @@ 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: append([]*ffcli.Command{
Subcommands: nonNilCmds(
upCmd,
downCmd,
setCmd,
loginCmd,
logoutCmd,
switchCmd,
configureCmd,
configureCmd(),
syspolicyCmd,
netcheckCmd,
ipCmd,
@@ -214,7 +215,9 @@ change in the future.
debugCmd,
driveCmd,
idTokenCmd,
}, maybeAdvertiseCmd()...),
advertiseCmd(),
configureHostCmd(),
),
FlagSet: rootfs,
Exec: func(ctx context.Context, args []string) error {
if len(args) > 0 {
@@ -224,10 +227,6 @@ change in the future.
},
}
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
}
walkCommands(rootCmd, func(w cmdWalk) bool {
if w.UsageFunc == nil {
w.UsageFunc = usageFunc
@@ -239,6 +238,10 @@ change in the future.
return rootCmd
}
func nonNilCmds(cmds ...*ffcli.Command) []*ffcli.Command {
return slicesx.AppendNonzero(cmds[:0], cmds)
}
func fatalf(format string, a ...any) {
if Fatalf != nil {
Fatalf(format, a...)

View File

@@ -17,6 +17,7 @@ import (
qt "github.com/frankban/quicktest"
"github.com/google/go-cmp/cmp"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
"tailscale.com/health/healthmsg"
"tailscale.com/ipn"
@@ -1525,3 +1526,45 @@ func TestHelpAlias(t *testing.T) {
t.Fatalf("Run: %v", err)
}
}
func TestDocs(t *testing.T) {
root := newRootCmd()
check := func(t *testing.T, c *ffcli.Command) {
shortVerb, _, ok := strings.Cut(c.ShortHelp, " ")
if !ok || shortVerb == "" {
t.Errorf("couldn't find verb+space in ShortHelp")
} else {
if strings.HasSuffix(shortVerb, ".") {
t.Errorf("ShortHelp shouldn't end in period; got %q", c.ShortHelp)
}
if b := shortVerb[0]; b >= 'a' && b <= 'z' {
t.Errorf("ShortHelp should start with upper-case letter; got %q", c.ShortHelp)
}
if strings.HasSuffix(shortVerb, "s") && shortVerb != "Does" {
t.Errorf("verb %q ending in 's' is unexpected, from %q", shortVerb, c.ShortHelp)
}
}
name := t.Name()
wantPfx := strings.ReplaceAll(strings.TrimPrefix(name, "TestDocs/"), "/", " ")
switch name {
case "TestDocs/tailscale/completion/bash",
"TestDocs/tailscale/completion/zsh":
wantPfx = "" // special-case exceptions
}
if !strings.HasPrefix(c.ShortUsage, wantPfx) {
t.Errorf("ShortUsage should start with %q; got %q", wantPfx, c.ShortUsage)
}
}
var walk func(t *testing.T, c *ffcli.Command)
walk = func(t *testing.T, c *ffcli.Command) {
t.Run(c.Name, func(t *testing.T) {
check(t, c)
for _, sub := range c.Subcommands {
walk(t, sub)
}
})
}
walk(t, root)
}

View File

@@ -20,33 +20,31 @@ import (
"tailscale.com/version"
)
func init() {
configureCmd.Subcommands = append(configureCmd.Subcommands, configureKubeconfigCmd)
}
var configureKubeconfigCmd = &ffcli.Command{
Name: "kubeconfig",
ShortHelp: "[ALPHA] Connect to a Kubernetes cluster using a Tailscale Auth Proxy",
ShortUsage: "tailscale configure kubeconfig <hostname-or-fqdn>",
LongHelp: strings.TrimSpace(`
func configureKubeconfigCmd() *ffcli.Command {
return &ffcli.Command{
Name: "kubeconfig",
ShortHelp: "[ALPHA] Connect to a Kubernetes cluster using a Tailscale Auth Proxy",
ShortUsage: "tailscale configure kubeconfig <hostname-or-fqdn>",
LongHelp: strings.TrimSpace(`
Run this command to configure kubectl to connect to a Kubernetes cluster over Tailscale.
The hostname argument should be set to the Tailscale hostname of the peer running as an auth proxy in the cluster.
See: https://tailscale.com/s/k8s-auth-proxy
`),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("kubeconfig")
return fs
})(),
Exec: runConfigureKubeconfig,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("kubeconfig")
return fs
})(),
Exec: runConfigureKubeconfig,
}
}
// kubeconfigPath returns the path to the kubeconfig file for the current user.
func kubeconfigPath() (string, error) {
if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" {
if version.IsSandboxedMacOS() {
return "", errors.New("$KUBECONFIG is incompatible with the App Store version")
return "", errors.New("cannot read $KUBECONFIG on GUI builds of the macOS client: this requires the open-source tailscaled distribution")
}
var out string
for _, out = range filepath.SplitList(kubeconfig) {

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_kube
package cli
import "github.com/peterbourgon/ff/v3/ffcli"
func configureKubeconfigCmd() *ffcli.Command {
// omitted from the build when the ts_omit_kube build tag is set
return nil
}

View File

@@ -22,22 +22,27 @@ import (
"tailscale.com/version/distro"
)
var synologyConfigureCertCmd = &ffcli.Command{
Name: "synology-cert",
Exec: runConfigureSynologyCert,
ShortHelp: "Configure Synology with a TLS certificate for your tailnet",
ShortUsage: "synology-cert [--domain <domain>]",
LongHelp: strings.TrimSpace(`
func synologyConfigureCertCmd() *ffcli.Command {
if runtime.GOOS != "linux" || distro.Get() != distro.Synology {
return nil
}
return &ffcli.Command{
Name: "synology-cert",
Exec: runConfigureSynologyCert,
ShortHelp: "Configure Synology with a TLS certificate for your tailnet",
ShortUsage: "synology-cert [--domain <domain>]",
LongHelp: strings.TrimSpace(`
This command is intended to run periodically as root on a Synology device to
create or refresh the TLS certificate for the tailnet domain.
See: https://tailscale.com/kb/1153/enabling-https
`),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("synology-cert")
fs.StringVar(&synologyConfigureCertArgs.domain, "domain", "", "Tailnet domain to create or refresh certificates for. Ignored if only one domain exists.")
return fs
})(),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("synology-cert")
fs.StringVar(&synologyConfigureCertArgs.domain, "domain", "", "Tailnet domain to create or refresh certificates for. Ignored if only one domain exists.")
return fs
})(),
}
}
var synologyConfigureCertArgs struct {

View File

@@ -21,34 +21,49 @@ import (
// configureHostCmd is the "tailscale configure-host" command which was once
// used to configure Synology devices, but is now a compatibility alias to
// "tailscale configure synology".
var configureHostCmd = &ffcli.Command{
Name: "configure-host",
Exec: runConfigureSynology,
ShortUsage: "tailscale configure-host\n" + synologyConfigureCmd.ShortUsage,
ShortHelp: synologyConfigureCmd.ShortHelp,
LongHelp: hidden + synologyConfigureCmd.LongHelp,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("configure-host")
return fs
})(),
//
// It returns nil if the actual "tailscale configure synology" command is not
// available.
func configureHostCmd() *ffcli.Command {
synologyConfigureCmd := synologyConfigureCmd()
if synologyConfigureCmd == nil {
// No need to offer this compatibility alias if the actual command is not available.
return nil
}
return &ffcli.Command{
Name: "configure-host",
Exec: runConfigureSynology,
ShortUsage: "tailscale configure-host\n" + synologyConfigureCmd.ShortUsage,
ShortHelp: synologyConfigureCmd.ShortHelp,
LongHelp: hidden + synologyConfigureCmd.LongHelp,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("configure-host")
return fs
})(),
}
}
var synologyConfigureCmd = &ffcli.Command{
Name: "synology",
Exec: runConfigureSynology,
ShortUsage: "tailscale configure synology",
ShortHelp: "Configure Synology to enable outbound connections",
LongHelp: strings.TrimSpace(`
func synologyConfigureCmd() *ffcli.Command {
if runtime.GOOS != "linux" || distro.Get() != distro.Synology {
return nil
}
return &ffcli.Command{
Name: "synology",
Exec: runConfigureSynology,
ShortUsage: "tailscale configure synology",
ShortHelp: "Configure Synology to enable outbound connections",
LongHelp: strings.TrimSpace(`
This command is intended to run at boot as root on a Synology device to
create the /dev/net/tun device and give the tailscaled binary permission
to use it.
See: https://tailscale.com/s/synology-outbound
`),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("synology")
return fs
})(),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("synology")
return fs
})(),
}
}
func runConfigureSynology(ctx context.Context, args []string) error {

View File

@@ -5,32 +5,41 @@ package cli
import (
"flag"
"runtime"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/version/distro"
)
var configureCmd = &ffcli.Command{
Name: "configure",
ShortUsage: "tailscale configure <subcommand>",
ShortHelp: "[ALPHA] Configure the host to enable more Tailscale features",
LongHelp: strings.TrimSpace(`
func configureCmd() *ffcli.Command {
return &ffcli.Command{
Name: "configure",
ShortUsage: "tailscale configure <subcommand>",
ShortHelp: "Configure the host to enable more Tailscale features",
LongHelp: strings.TrimSpace(`
The 'configure' set of commands are intended to provide a way to enable different
services on the host to use Tailscale in more ways.
`),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("configure")
return fs
})(),
Subcommands: configureSubcommands(),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("configure")
return fs
})(),
Subcommands: nonNilCmds(
configureKubeconfigCmd(),
synologyConfigureCmd(),
synologyConfigureCertCmd(),
ccall(maybeSysExtCmd),
ccall(maybeVPNConfigCmd),
),
}
}
func configureSubcommands() (out []*ffcli.Command) {
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
out = append(out, synologyConfigureCmd)
out = append(out, synologyConfigureCertCmd)
// ccall calls the function f if it is non-nil, and returns its result.
//
// It returns the zero value of the type T if f is nil.
func ccall[T any](f func() T) T {
var zero T
if f == nil {
return zero
}
return out
return f()
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import "github.com/peterbourgon/ff/v3/ffcli"
var (
maybeSysExtCmd func() *ffcli.Command // non-nil only on macOS, see configure_apple.go
maybeVPNConfigCmd func() *ffcli.Command // non-nil only on macOS, see configure_apple.go
)

View File

@@ -0,0 +1,97 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build darwin
package cli
import (
"context"
"errors"
"github.com/peterbourgon/ff/v3/ffcli"
)
func init() {
maybeSysExtCmd = sysExtCmd
maybeVPNConfigCmd = vpnConfigCmd
}
// Functions in this file provide a dummy Exec function that only prints an error message for users of the open-source
// tailscaled distribution. On GUI builds, the Swift code in the macOS client handles these commands by not passing the
// flow of execution to the CLI.
// sysExtCmd returns a command for managing the Tailscale system extension on macOS
// (for the Standalone variant of the client only).
func sysExtCmd() *ffcli.Command {
return &ffcli.Command{
Name: "sysext",
ShortUsage: "tailscale configure sysext [activate|deactivate|status]",
ShortHelp: "Manage the system extension for macOS (Standalone variant)",
LongHelp: "The sysext set of commands provides a way to activate, deactivate, or manage the state of the Tailscale system extension on macOS. " +
"This is only relevant if you are running the Standalone variant of the Tailscale client for macOS. " +
"To access more detailed information about system extensions installed on this Mac, run 'systemextensionsctl list'.",
Subcommands: []*ffcli.Command{
{
Name: "activate",
ShortUsage: "tailscale configure sysext activate",
ShortHelp: "Register the Tailscale system extension with macOS.",
LongHelp: "This command registers the Tailscale system extension with macOS. To run Tailscale, you'll also need to install the VPN configuration separately (run `tailscale configure vpn-config install`). After running this command, you need to approve the extension in System Settings > Login Items and Extensions > Network Extensions.",
Exec: requiresStandalone,
},
{
Name: "deactivate",
ShortUsage: "tailscale configure sysext deactivate",
ShortHelp: "Deactivate the Tailscale system extension on macOS",
LongHelp: "This command deactivates the Tailscale system extension on macOS. To completely remove Tailscale, you'll also need to delete the VPN configuration separately (use `tailscale configure vpn-config uninstall`).",
Exec: requiresStandalone,
},
{
Name: "status",
ShortUsage: "tailscale configure sysext status",
ShortHelp: "Print the enablement status of the Tailscale system extension",
LongHelp: "This command prints the enablement status of the Tailscale system extension. If the extension is not enabled, run `tailscale sysext activate` to enable it.",
Exec: requiresStandalone,
},
},
Exec: requiresStandalone,
}
}
// vpnConfigCmd returns a command for managing the Tailscale VPN configuration on macOS
// (the entry that appears in System Settings > VPN).
func vpnConfigCmd() *ffcli.Command {
return &ffcli.Command{
Name: "mac-vpn",
ShortUsage: "tailscale configure mac-vpn [install|uninstall]",
ShortHelp: "Manage the VPN configuration on macOS (App Store and Standalone variants)",
LongHelp: "The vpn-config set of commands provides a way to add or remove the Tailscale VPN configuration from the macOS settings. This is the entry that appears in System Settings > VPN.",
Subcommands: []*ffcli.Command{
{
Name: "install",
ShortUsage: "tailscale configure mac-vpn install",
ShortHelp: "Write the Tailscale VPN configuration to the macOS settings",
LongHelp: "This command writes the Tailscale VPN configuration to the macOS settings. This is the entry that appears in System Settings > VPN. If you are running the Standalone variant of the client, you'll also need to install the system extension separately (run `tailscale configure sysext activate`).",
Exec: requiresGUI,
},
{
Name: "uninstall",
ShortUsage: "tailscale configure mac-vpn uninstall",
ShortHelp: "Delete the Tailscale VPN configuration from the macOS settings",
LongHelp: "This command removes the Tailscale VPN configuration from the macOS settings. This is the entry that appears in System Settings > VPN. If you are running the Standalone variant of the client, you'll also need to deactivate the system extension separately (run `tailscale configure sysext deactivate`).",
Exec: requiresGUI,
},
},
Exec: func(ctx context.Context, args []string) error {
return errors.New("unsupported command: requires a GUI build of the macOS client")
},
}
}
func requiresStandalone(ctx context.Context, args []string) error {
return errors.New("unsupported command: requires the Standalone (.pkg installer) GUI build of the client")
}
func requiresGUI(ctx context.Context, args []string) error {
return errors.New("unsupported command: requires a GUI build of the macOS client")
}

View File

@@ -289,7 +289,7 @@ var debugCmd = &ffcli.Command{
Name: "capture",
ShortUsage: "tailscale debug capture",
Exec: runCapture,
ShortHelp: "Streams pcaps for debugging",
ShortHelp: "Stream pcaps for debugging",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("capture")
fs.StringVar(&captureArgs.outFile, "o", "", "path to stream the pcap (or - for stdout), leave empty to start wireshark")
@@ -315,13 +315,13 @@ var debugCmd = &ffcli.Command{
Name: "peer-endpoint-changes",
ShortUsage: "tailscale debug peer-endpoint-changes <hostname-or-IP>",
Exec: runPeerEndpointChanges,
ShortHelp: "Prints debug information about a peer's endpoint changes",
ShortHelp: "Print debug information about a peer's endpoint changes",
},
{
Name: "dial-types",
ShortUsage: "tailscale debug dial-types <hostname-or-IP> <port>",
Exec: runDebugDialTypes,
ShortHelp: "Prints debug information about connecting to a given host or IP",
ShortHelp: "Print debug information about connecting to a given host or IP",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("dial-types")
fs.StringVar(&debugDialTypesArgs.network, "network", "tcp", `network type to dial ("tcp", "udp", etc.)`)
@@ -342,7 +342,7 @@ var debugCmd = &ffcli.Command{
{
Name: "go-buildinfo",
ShortUsage: "tailscale debug go-buildinfo",
ShortHelp: "Prints Go's runtime/debug.BuildInfo",
ShortHelp: "Print Go's runtime/debug.BuildInfo",
Exec: runGoBuildInfo,
},
},

View File

@@ -20,7 +20,7 @@ var dnsCmd = &ffcli.Command{
Name: "status",
ShortUsage: "tailscale dns status [--all]",
Exec: runDNSStatus,
ShortHelp: "Prints the current DNS status and configuration",
ShortHelp: "Print the current DNS status and configuration",
LongHelp: dnsStatusLongHelp(),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("status")

View File

@@ -41,7 +41,7 @@ func exitNodeCmd() *ffcli.Command {
{
Name: "suggest",
ShortUsage: "tailscale exit-node suggest",
ShortHelp: "Suggests the best available exit node",
ShortHelp: "Suggest the best available exit node",
Exec: runExitNodeSuggest,
}},
(func() []*ffcli.Command {

View File

@@ -33,13 +33,13 @@ https://tailscale.com/s/client-metrics
Name: "print",
ShortUsage: "tailscale metrics print",
Exec: runMetricsPrint,
ShortHelp: "Prints current metric values in the Prometheus text exposition format",
ShortHelp: "Print current metric values in Prometheus text format",
},
{
Name: "write",
ShortUsage: "tailscale metrics write <path>",
Exec: runMetricsWrite,
ShortHelp: "Writes metric values to a file",
ShortHelp: "Write metric values to a file",
LongHelp: strings.TrimSpace(`
The 'tailscale metrics write' command writes metric values to a text file provided as its

View File

@@ -191,8 +191,7 @@ var nlStatusArgs struct {
var nlStatusCmd = &ffcli.Command{
Name: "status",
ShortUsage: "tailscale lock status",
ShortHelp: "Outputs the state of tailnet lock",
LongHelp: "Outputs the state of tailnet lock",
ShortHelp: "Output the state of tailnet lock",
Exec: runNetworkLockStatus,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock status")
@@ -293,8 +292,7 @@ func runNetworkLockStatus(ctx context.Context, args []string) error {
var nlAddCmd = &ffcli.Command{
Name: "add",
ShortUsage: "tailscale lock add <public-key>...",
ShortHelp: "Adds one or more trusted signing keys to tailnet lock",
LongHelp: "Adds one or more trusted signing keys to tailnet lock",
ShortHelp: "Add one or more trusted signing keys to tailnet lock",
Exec: func(ctx context.Context, args []string) error {
return runNetworkLockModify(ctx, args, nil)
},
@@ -307,8 +305,7 @@ var nlRemoveArgs struct {
var nlRemoveCmd = &ffcli.Command{
Name: "remove",
ShortUsage: "tailscale lock remove [--re-sign=false] <public-key>...",
ShortHelp: "Removes one or more trusted signing keys from tailnet lock",
LongHelp: "Removes one or more trusted signing keys from tailnet lock",
ShortHelp: "Remove one or more trusted signing keys from tailnet lock",
Exec: runNetworkLockRemove,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock remove")
@@ -448,7 +445,7 @@ func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) err
var nlSignCmd = &ffcli.Command{
Name: "sign",
ShortUsage: "tailscale lock sign <node-key> [<rotation-key>]\ntailscale lock sign <auth-key>",
ShortHelp: "Signs a node or pre-approved auth key",
ShortHelp: "Sign a node or pre-approved auth key",
LongHelp: `Either:
- signs a node key and transmits the signature to the coordination
server, or
@@ -510,7 +507,7 @@ func runNetworkLockSign(ctx context.Context, args []string) error {
var nlDisableCmd = &ffcli.Command{
Name: "disable",
ShortUsage: "tailscale lock disable <disablement-secret>",
ShortHelp: "Consumes a disablement secret to shut down tailnet lock for the tailnet",
ShortHelp: "Consume a disablement secret to shut down tailnet lock for the tailnet",
LongHelp: strings.TrimSpace(`
The 'tailscale lock disable' command uses the specified disablement
@@ -539,7 +536,7 @@ func runNetworkLockDisable(ctx context.Context, args []string) error {
var nlLocalDisableCmd = &ffcli.Command{
Name: "local-disable",
ShortUsage: "tailscale lock local-disable",
ShortHelp: "Disables tailnet lock for this node only",
ShortHelp: "Disable tailnet lock for this node only",
LongHelp: strings.TrimSpace(`
The 'tailscale lock local-disable' command disables tailnet lock for only
@@ -561,8 +558,8 @@ func runNetworkLockLocalDisable(ctx context.Context, args []string) error {
var nlDisablementKDFCmd = &ffcli.Command{
Name: "disablement-kdf",
ShortUsage: "tailscale lock disablement-kdf <hex-encoded-disablement-secret>",
ShortHelp: "Computes a disablement value from a disablement secret (advanced users only)",
LongHelp: "Computes a disablement value from a disablement secret (advanced users only)",
ShortHelp: "Compute a disablement value from a disablement secret (advanced users only)",
LongHelp: "Compute a disablement value from a disablement secret (advanced users only)",
Exec: runNetworkLockDisablementKDF,
}

View File

@@ -20,7 +20,7 @@ import (
var switchCmd = &ffcli.Command{
Name: "switch",
ShortUsage: "tailscale switch <id>",
ShortHelp: "Switches to a different Tailscale account",
ShortHelp: "Switch to a different Tailscale account",
LongHelp: `"tailscale switch" switches between logged in accounts. You can
use the ID that's returned from 'tailnet switch -list'
to pick which profile you want to switch to. Alternatively, you

View File

@@ -31,7 +31,7 @@ var syspolicyCmd = &ffcli.Command{
Name: "list",
ShortUsage: "tailscale syspolicy list",
Exec: runSysPolicyList,
ShortHelp: "Prints effective policy settings",
ShortHelp: "Print 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")
@@ -43,7 +43,7 @@ var syspolicyCmd = &ffcli.Command{
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",
ShortHelp: "Force 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")

View File

@@ -97,6 +97,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/licenses from tailscale.com/client/web+
tailscale.com/metrics from tailscale.com/derp+
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial
tailscale.com/net/captivedetection from tailscale.com/net/netcheck
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
tailscale.com/net/dnscache from tailscale.com/control/controlhttp+

View File

@@ -112,7 +112,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify/v2 from tailscale.com/net/dns
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/feature/tap
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4
@@ -127,7 +127,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd
github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe
github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd
github.com/kortschak/wol from tailscale.com/ipn/ipnlocal
github.com/kortschak/wol from tailscale.com/feature/wakeonlan
LD github.com/kr/fs from github.com/pkg/sftp
L github.com/mdlayher/genetlink from tailscale.com/net/tstun
L 💣 github.com/mdlayher/netlink from github.com/google/nftables+
@@ -214,7 +214,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
gvisor.dev/gvisor/pkg/tcpip/network/internal/fragmentation from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
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/ipv4 from tailscale.com/feature/tap+
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+
@@ -259,6 +259,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
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/feature from tailscale.com/feature/wakeonlan+
tailscale.com/feature/condregister from tailscale.com/cmd/tailscaled
L tailscale.com/feature/tap from tailscale.com/feature/condregister
tailscale.com/feature/wakeonlan from tailscale.com/feature/condregister
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+
@@ -286,6 +290,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/logtail/backoff from tailscale.com/cmd/tailscaled+
tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+
tailscale.com/metrics from tailscale.com/derp+
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
tailscale.com/net/connstats from tailscale.com/net/tstun+
tailscale.com/net/dns from tailscale.com/cmd/tailscaled+

View File

@@ -35,6 +35,7 @@ import (
"tailscale.com/control/controlclient"
"tailscale.com/drive/driveimpl"
"tailscale.com/envknob"
_ "tailscale.com/feature/condregister"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/conffile"

View File

@@ -55,6 +55,7 @@ import (
"tailscale.com/util/osdiag"
"tailscale.com/util/syspolicy"
"tailscale.com/util/winutil"
"tailscale.com/util/winutil/gp"
"tailscale.com/version"
"tailscale.com/wf"
)
@@ -70,6 +71,22 @@ func init() {
}
}
// permitPolicyLocks is a function to be called to lift the restriction on acquiring
// [gp.PolicyLock]s once the service is running.
// It is safe to be called multiple times.
var permitPolicyLocks = func() {}
func init() {
if isWindowsService() {
// We prevent [gp.PolicyLock]s from being acquired until the service enters the running state.
// Otherwise, if tailscaled starts due to a GPSI policy installing Tailscale, it may deadlock
// while waiting for the write counterpart of the GP lock to be released by Group Policy,
// which is itself waiting for the installation to complete and tailscaled to start.
// See tailscale/tailscale#14416 for more information.
permitPolicyLocks = gp.RestrictPolicyLocks()
}
}
const serviceName = "Tailscale"
// Application-defined command codes between 128 and 255
@@ -109,13 +126,13 @@ func tstunNewWithWindowsRetries(logf logger.Logf, tunName string) (_ tun.Device,
}
}
func isWindowsService() bool {
var isWindowsService = sync.OnceValue(func() bool {
v, err := svc.IsWindowsService()
if err != nil {
log.Fatalf("svc.IsWindowsService failed: %v", err)
}
return v
}
})
// syslogf is a logger function that writes to the Windows event log (ie, the
// one that you see in the Windows Event Viewer). tailscaled may optionally
@@ -180,6 +197,10 @@ func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, ch
changes <- svc.Status{State: svc.Running, Accepts: svcAccepts}
syslogf("Service running")
// It is safe to allow GP locks to be acquired now that the service
// is running.
permitPolicyLocks()
for {
select {
case <-doneCh:

View File

@@ -195,6 +195,10 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
ms.updateStateFromResponse(resp)
// Occasionally clean up old userprofile if it grows too much
// from e.g. ephemeral tagged nodes.
ms.cleanLastUserProfile()
if ms.tryHandleIncrementally(resp) {
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
return nil
@@ -292,7 +296,6 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
for _, up := range resp.UserProfiles {
ms.lastUserProfile[up.ID] = up
}
// TODO(bradfitz): clean up old user profiles? maybe not worth it.
if dm := resp.DERPMap; dm != nil {
ms.vlogf("netmap: new map contains DERP map")
@@ -532,6 +535,32 @@ func (ms *mapSession) addUserProfile(nm *netmap.NetworkMap, userID tailcfg.UserI
}
}
// cleanLastUserProfile deletes any entries from lastUserProfile
// that are not referenced by any peer or the self node.
//
// This is expensive enough that we don't do this on every message
// from the server, but only when it's grown enough to matter.
func (ms *mapSession) cleanLastUserProfile() {
if len(ms.lastUserProfile) < len(ms.peers)*2 {
// Hasn't grown enough to be worth cleaning.
return
}
keep := set.Set[tailcfg.UserID]{}
if node := ms.lastNode; node.Valid() {
keep.Add(node.User())
}
for _, n := range ms.peers {
keep.Add(n.User())
keep.Add(n.Sharer())
}
for userID := range ms.lastUserProfile {
if !keep.Contains(userID) {
delete(ms.lastUserProfile, userID)
}
}
}
var debugPatchifyPeer = envknob.RegisterBool("TS_DEBUG_PATCHIFY_PEER")
// patchifyPeersChanged mutates resp to promote PeersChanged entries to PeersChangedPatch

View File

@@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"io"
"net"
"time"
)
@@ -79,8 +80,7 @@ const (
// framePeerGone to B so B can forget that a reverse path
// exists on that connection to get back to A. It is also sent
// if A tries to send a CallMeMaybe to B and the server has no
// record of B (which currently would only happen if there was
// a bug).
// record of B
framePeerGone = frameType(0x08) // 32B pub key of peer that's gone + 1 byte reason
// framePeerPresent is like framePeerGone, but for other members of the DERP
@@ -131,8 +131,8 @@ const (
type PeerGoneReasonType byte
const (
PeerGoneReasonDisconnected = PeerGoneReasonType(0x00) // peer disconnected from this server
PeerGoneReasonNotHere = PeerGoneReasonType(0x01) // server doesn't know about this peer, unexpected
PeerGoneReasonDisconnected = PeerGoneReasonType(0x00) // is only sent when a peer disconnects from this server
PeerGoneReasonNotHere = PeerGoneReasonType(0x01) // server doesn't know about this peer
PeerGoneReasonMeshConnBroke = PeerGoneReasonType(0xf0) // invented by Client.RunWatchConnectionLoop on disconnect; not sent on the wire
)
@@ -255,3 +255,14 @@ func writeFrame(bw *bufio.Writer, t frameType, b []byte) error {
}
return bw.Flush()
}
// Conn is the subset of the underlying net.Conn the DERP Server needs.
// It is a defined type so that non-net connections can be used.
type Conn interface {
io.WriteCloser
LocalAddr() net.Addr
// The *Deadline methods follow the semantics of net.Conn.
SetDeadline(time.Time) error
SetReadDeadline(time.Time) error
SetWriteDeadline(time.Time) error
}

View File

@@ -23,7 +23,6 @@ import (
"math"
"math/big"
"math/rand/v2"
"net"
"net/http"
"net/netip"
"os"
@@ -85,7 +84,7 @@ func init() {
const (
defaultPerClientSendQueueDepth = 32 // default packets buffered for sending
writeTimeout = 2 * time.Second
DefaultTCPWiteTimeout = 2 * time.Second
privilegedWriteTimeout = 30 * time.Second // for clients with the mesh key
)
@@ -202,6 +201,8 @@ type Server struct {
// Sets the client send queue depth for the server.
perClientSendQueueDepth int
tcpWriteTimeout time.Duration
clock tstime.Clock
}
@@ -341,17 +342,6 @@ type PacketForwarder interface {
String() string
}
// Conn is the subset of the underlying net.Conn the DERP Server needs.
// It is a defined type so that non-net connections can be used.
type Conn interface {
io.WriteCloser
LocalAddr() net.Addr
// The *Deadline methods follow the semantics of net.Conn.
SetDeadline(time.Time) error
SetReadDeadline(time.Time) error
SetWriteDeadline(time.Time) error
}
var packetsDropped = metrics.NewMultiLabelMap[dropReasonKindLabels](
"derp_packets_dropped",
"counter",
@@ -389,6 +379,7 @@ func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
bufferedWriteFrames: metrics.NewHistogram([]float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 50, 100}),
keyOfAddr: map[netip.AddrPort]key.NodePublic{},
clock: tstime.StdClock{},
tcpWriteTimeout: DefaultTCPWiteTimeout,
}
s.initMetacert()
s.packetsRecvDisco = s.packetsRecvByKind.Get(string(packetKindDisco))
@@ -493,6 +484,13 @@ func (s *Server) SetVerifyClientURLFailOpen(v bool) {
s.verifyClientsURLFailOpen = v
}
// SetTCPWriteTimeout sets the timeout for writing to connected clients.
// This timeout does not apply to mesh connections.
// Defaults to 2 seconds.
func (s *Server) SetTCPWriteTimeout(d time.Duration) {
s.tcpWriteTimeout = d
}
// HasMeshKey reports whether the server is configured with a mesh key.
func (s *Server) HasMeshKey() bool { return s.meshKey != "" }
@@ -1817,7 +1815,7 @@ func (c *sclient) sendLoop(ctx context.Context) error {
}
func (c *sclient) setWriteDeadline() {
d := writeTimeout
d := c.s.tcpWriteTimeout
if c.canMesh {
// Trusted peers get more tolerance.
//
@@ -1829,7 +1827,10 @@ func (c *sclient) setWriteDeadline() {
// of connected peers.
d = privilegedWriteTimeout
}
c.nc.SetWriteDeadline(time.Now().Add(d))
// Ignore the error from setting the write deadline. In practice,
// setting the deadline will only fail if the connection is closed
// or closing, so the subsequent Write() will fail anyway.
_ = c.nc.SetWriteDeadline(time.Now().Add(d))
}
// sendKeepAlive sends a keep-alive frame, without flushing.

View File

@@ -0,0 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// The condregister package registers all conditional features guarded
// by build tags. It is one central package that callers can empty import
// to ensure all conditional features are registered.
package condregister

View File

@@ -0,0 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !ts_omit_tap
package condregister
import _ "tailscale.com/feature/tap"

View File

@@ -0,0 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_wakeonlan
package condregister
import _ "tailscale.com/feature/wakeonlan"

54
feature/feature.go Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package feature tracks which features are linked into the binary.
package feature
import "reflect"
var in = map[string]bool{}
// Register notes that the named feature is linked into the binary.
func Register(name string) {
if _, ok := in[name]; ok {
panic("duplicate feature registration for " + name)
}
in[name] = true
}
// Hook is a func that can only be set once.
//
// It is not safe for concurrent use.
type Hook[Func any] struct {
f Func
ok bool
}
// IsSet reports whether the hook has been set.
func (h *Hook[Func]) IsSet() bool {
return h.ok
}
// Set sets the hook function, panicking if it's already been set
// or f is the zero value.
//
// It's meant to be called in init.
func (h *Hook[Func]) Set(f Func) {
if h.ok {
panic("Set on already-set feature hook")
}
if reflect.ValueOf(f).IsZero() {
panic("Set with zero value")
}
h.f = f
h.ok = true
}
// Get returns the hook function, or panics if it hasn't been set.
// Use IsSet to check if it's been set.
func (h *Hook[Func]) Get() Func {
if !h.ok {
panic("Get on unset feature hook, without IsSet")
}
return h.f
}

View File

@@ -1,9 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tap
package tstun
// Package tap registers Tailscale's experimental (demo) Linux TAP (Layer 2) support.
package tap
import (
"bytes"
@@ -26,6 +25,7 @@ import (
"tailscale.com/net/netaddr"
"tailscale.com/net/packet"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tstun"
"tailscale.com/syncs"
"tailscale.com/types/ipproto"
"tailscale.com/types/logger"
@@ -38,7 +38,11 @@ import (
// For now just hard code it.
var ourMAC = net.HardwareAddr{0x30, 0x2D, 0x66, 0xEC, 0x7A, 0x93}
func init() { createTAP = createTAPLinux }
const tapDebug = tstun.TAPDebug
func init() {
tstun.CreateTAP.Set(createTAPLinux)
}
func createTAPLinux(logf logger.Logf, tapName, bridgeName string) (tun.Device, error) {
fd, err := unix.Open("/dev/net/tun", unix.O_RDWR, 0)
@@ -87,7 +91,10 @@ var (
etherTypeIPv6 = etherType{0x86, 0xDD}
)
const ipv4HeaderLen = 20
const (
ipv4HeaderLen = 20
ethernetFrameSize = 14 // 2 six byte MACs, 2 bytes ethertype
)
const (
consumePacket = true
@@ -186,6 +193,11 @@ var (
cgnatNetMask = net.IPMask(net.ParseIP("255.192.0.0").To4())
)
// parsedPacketPool holds a pool of Parsed structs for use in filtering.
// This is needed because escape analysis cannot see that parsed packets
// do not escape through {Pre,Post}Filter{In,Out}.
var parsedPacketPool = sync.Pool{New: func() any { return new(packet.Parsed) }}
// 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.
@@ -392,7 +404,7 @@ type tapDevice struct {
destMACAtomic syncs.AtomicValue[[6]byte]
}
var _ setIPer = (*tapDevice)(nil)
var _ tstun.SetIPer = (*tapDevice)(nil)
func (t *tapDevice) SetIP(ipV4, ipV6TODO netip.Addr) error {
t.clientIPv4.Store(ipV4.String())

View File

@@ -0,0 +1,243 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package wakeonlan registers the Wake-on-LAN feature.
package wakeonlan
import (
"encoding/json"
"log"
"net"
"net/http"
"runtime"
"sort"
"strings"
"unicode"
"github.com/kortschak/wol"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/hostinfo"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
)
func init() {
feature.Register("wakeonlan")
ipnlocal.RegisterC2N("POST /wol", handleC2NWoL)
ipnlocal.RegisterPeerAPIHandler("/v0/wol", handlePeerAPIWakeOnLAN)
hostinfo.RegisterHostinfoNewHook(func(h *tailcfg.Hostinfo) {
h.WoLMACs = getWoLMACs()
})
}
func handleC2NWoL(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) {
r.ParseForm()
var macs []net.HardwareAddr
for _, macStr := range r.Form["mac"] {
mac, err := net.ParseMAC(macStr)
if err != nil {
http.Error(w, "bad 'mac' param", http.StatusBadRequest)
return
}
macs = append(macs, mac)
}
var res struct {
SentTo []string
Errors []string
}
st := b.NetMon().InterfaceState()
if st == nil {
res.Errors = append(res.Errors, "no interface state")
writeJSON(w, &res)
return
}
var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
for _, mac := range macs {
for ifName, ips := range st.InterfaceIPs {
for _, ip := range ips {
if ip.Addr().IsLoopback() || ip.Addr().Is6() {
continue
}
local := &net.UDPAddr{
IP: ip.Addr().AsSlice(),
Port: 0,
}
remote := &net.UDPAddr{
IP: net.IPv4bcast,
Port: 0,
}
if err := wol.Wake(mac, password, local, remote); err != nil {
res.Errors = append(res.Errors, err.Error())
} else {
res.SentTo = append(res.SentTo, ifName)
}
break // one per interface is enough
}
}
}
sort.Strings(res.SentTo)
writeJSON(w, &res)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func canWakeOnLAN(h ipnlocal.PeerAPIHandler) bool {
if h.Peer().UnsignedPeerAPIOnly() {
return false
}
return h.IsSelfUntagged() || h.PeerCaps().HasCapability(tailcfg.PeerCapabilityWakeOnLAN)
}
var metricWakeOnLANCalls = clientmetric.NewCounter("peerapi_wol")
func handlePeerAPIWakeOnLAN(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) {
metricWakeOnLANCalls.Add(1)
if !canWakeOnLAN(h) {
http.Error(w, "no WoL access", http.StatusForbidden)
return
}
if r.Method != "POST" {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
macStr := r.FormValue("mac")
if macStr == "" {
http.Error(w, "missing 'mac' param", http.StatusBadRequest)
return
}
mac, err := net.ParseMAC(macStr)
if err != nil {
http.Error(w, "bad 'mac' param", http.StatusBadRequest)
return
}
var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
st := h.LocalBackend().NetMon().InterfaceState()
if st == nil {
http.Error(w, "failed to get interfaces state", http.StatusInternalServerError)
return
}
var res struct {
SentTo []string
Errors []string
}
for ifName, ips := range st.InterfaceIPs {
for _, ip := range ips {
if ip.Addr().IsLoopback() || ip.Addr().Is6() {
continue
}
local := &net.UDPAddr{
IP: ip.Addr().AsSlice(),
Port: 0,
}
remote := &net.UDPAddr{
IP: net.IPv4bcast,
Port: 0,
}
if err := wol.Wake(mac, password, local, remote); err != nil {
res.Errors = append(res.Errors, err.Error())
} else {
res.SentTo = append(res.SentTo, ifName)
}
break // one per interface is enough
}
}
sort.Strings(res.SentTo)
writeJSON(w, res)
}
// TODO(bradfitz): this is all too simplistic and static. It needs to run
// continuously in response to netmon events (USB ethernet adapters might get
// plugged in) and look for the media type/status/etc. Right now on macOS it
// still detects a half dozen "up" en0, en1, en2, en3 etc interfaces that don't
// have any media. We should only report the one that's actually connected.
// But it works for now (2023-10-05) for fleshing out the rest.
var wakeMAC = envknob.RegisterString("TS_WAKE_MAC") // mac address, "false" or "auto". for https://github.com/tailscale/tailscale/issues/306
// getWoLMACs returns up to 10 MAC address of the local machine to send
// wake-on-LAN packets to in order to wake it up. The returned MACs are in
// lowercase hex colon-separated form ("xx:xx:xx:xx:xx:xx").
//
// If TS_WAKE_MAC=auto, it tries to automatically find the MACs based on the OS
// type and interface properties. (TODO(bradfitz): incomplete) If TS_WAKE_MAC is
// set to a MAC address, that sole MAC address is returned.
func getWoLMACs() (macs []string) {
switch runtime.GOOS {
case "ios", "android":
return nil
}
if s := wakeMAC(); s != "" {
switch s {
case "auto":
ifs, _ := net.Interfaces()
for _, iface := range ifs {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if iface.Flags&net.FlagBroadcast == 0 ||
iface.Flags&net.FlagRunning == 0 ||
iface.Flags&net.FlagUp == 0 {
continue
}
if keepMAC(iface.Name, iface.HardwareAddr) {
macs = append(macs, iface.HardwareAddr.String())
}
if len(macs) == 10 {
break
}
}
return macs
case "false", "off": // fast path before ParseMAC error
return nil
}
mac, err := net.ParseMAC(s)
if err != nil {
log.Printf("invalid MAC %q", s)
return nil
}
return []string{mac.String()}
}
return nil
}
var ignoreWakeOUI = map[[3]byte]bool{
{0x00, 0x15, 0x5d}: true, // Hyper-V
{0x00, 0x50, 0x56}: true, // VMware
{0x00, 0x1c, 0x14}: true, // VMware
{0x00, 0x05, 0x69}: true, // VMware
{0x00, 0x0c, 0x29}: true, // VMware
{0x00, 0x1c, 0x42}: true, // Parallels
{0x08, 0x00, 0x27}: true, // VirtualBox
{0x00, 0x21, 0xf6}: true, // VirtualBox
{0x00, 0x14, 0x4f}: true, // VirtualBox
{0x00, 0x0f, 0x4b}: true, // VirtualBox
{0x52, 0x54, 0x00}: true, // VirtualBox/Vagrant
}
func keepMAC(ifName string, mac []byte) bool {
if len(mac) != 6 {
return false
}
base := strings.TrimRightFunc(ifName, unicode.IsNumber)
switch runtime.GOOS {
case "darwin":
switch base {
case "llw", "awdl", "utun", "bridge", "lo", "gif", "stf", "anpi", "ap":
return false
}
}
if mac[0] == 0x02 && mac[1] == 0x42 {
// Docker container.
return false
}
oui := [3]byte{mac[0], mac[1], mac[2]}
if ignoreWakeOUI[oui] {
return false
}
return true
}

2
go.mod
View File

@@ -49,7 +49,7 @@ require (
github.com/goreleaser/nfpm/v2 v2.33.1
github.com/hdevalence/ed25519consensus v0.2.0
github.com/illarion/gonotify/v2 v2.0.3
github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c
github.com/inetaf/tcpproxy v0.0.0-20250121183218-48c7e53d7ac4
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2
github.com/jellydator/ttlcache/v3 v3.1.0
github.com/jsimonetti/rtnetlink v1.4.0

4
go.sum
View File

@@ -572,8 +572,8 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c h1:gYfYE403/nlrGNYj6BEOs9ucLCAGB9gstlSk92DttTg=
github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI=
github.com/inetaf/tcpproxy v0.0.0-20250121183218-48c7e53d7ac4 h1:5u/LhBmv8Y+BhTTADTuh8ma0DcZ3zzx+GINbMeMG9nM=
github.com/inetaf/tcpproxy v0.0.0-20250121183218-48c7e53d7ac4/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=

View File

@@ -32,11 +32,19 @@ import (
var started = time.Now()
var newHooks []func(*tailcfg.Hostinfo)
// RegisterHostinfoNewHook registers a callback to be called on a non-nil
// [tailcfg.Hostinfo] before it is returned by [New].
func RegisterHostinfoNewHook(f func(*tailcfg.Hostinfo)) {
newHooks = append(newHooks, f)
}
// New returns a partially populated Hostinfo for the current host.
func New() *tailcfg.Hostinfo {
hostname, _ := os.Hostname()
hostname = dnsname.FirstLabel(hostname)
return &tailcfg.Hostinfo{
hi := &tailcfg.Hostinfo{
IPNVersion: version.Long(),
Hostname: hostname,
App: appTypeCached(),
@@ -57,8 +65,11 @@ func New() *tailcfg.Hostinfo {
Cloud: string(cloudenv.Get()),
NoLogsNoSupport: envknob.NoLogsNoSupport(),
AllowsUpdate: envknob.AllowsRemoteUpdate(),
WoLMACs: getWoLMACs(),
}
for _, f := range newHooks {
f(hi)
}
return hi
}
// non-nil on some platforms

View File

@@ -1,106 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package hostinfo
import (
"log"
"net"
"runtime"
"strings"
"unicode"
"tailscale.com/envknob"
)
// TODO(bradfitz): this is all too simplistic and static. It needs to run
// continuously in response to netmon events (USB ethernet adapaters might get
// plugged in) and look for the media type/status/etc. Right now on macOS it
// still detects a half dozen "up" en0, en1, en2, en3 etc interfaces that don't
// have any media. We should only report the one that's actually connected.
// But it works for now (2023-10-05) for fleshing out the rest.
var wakeMAC = envknob.RegisterString("TS_WAKE_MAC") // mac address, "false" or "auto". for https://github.com/tailscale/tailscale/issues/306
// getWoLMACs returns up to 10 MAC address of the local machine to send
// wake-on-LAN packets to in order to wake it up. The returned MACs are in
// lowercase hex colon-separated form ("xx:xx:xx:xx:xx:xx").
//
// If TS_WAKE_MAC=auto, it tries to automatically find the MACs based on the OS
// type and interface properties. (TODO(bradfitz): incomplete) If TS_WAKE_MAC is
// set to a MAC address, that sole MAC address is returned.
func getWoLMACs() (macs []string) {
switch runtime.GOOS {
case "ios", "android":
return nil
}
if s := wakeMAC(); s != "" {
switch s {
case "auto":
ifs, _ := net.Interfaces()
for _, iface := range ifs {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if iface.Flags&net.FlagBroadcast == 0 ||
iface.Flags&net.FlagRunning == 0 ||
iface.Flags&net.FlagUp == 0 {
continue
}
if keepMAC(iface.Name, iface.HardwareAddr) {
macs = append(macs, iface.HardwareAddr.String())
}
if len(macs) == 10 {
break
}
}
return macs
case "false", "off": // fast path before ParseMAC error
return nil
}
mac, err := net.ParseMAC(s)
if err != nil {
log.Printf("invalid MAC %q", s)
return nil
}
return []string{mac.String()}
}
return nil
}
var ignoreWakeOUI = map[[3]byte]bool{
{0x00, 0x15, 0x5d}: true, // Hyper-V
{0x00, 0x50, 0x56}: true, // VMware
{0x00, 0x1c, 0x14}: true, // VMware
{0x00, 0x05, 0x69}: true, // VMware
{0x00, 0x0c, 0x29}: true, // VMware
{0x00, 0x1c, 0x42}: true, // Parallels
{0x08, 0x00, 0x27}: true, // VirtualBox
{0x00, 0x21, 0xf6}: true, // VirtualBox
{0x00, 0x14, 0x4f}: true, // VirtualBox
{0x00, 0x0f, 0x4b}: true, // VirtualBox
{0x52, 0x54, 0x00}: true, // VirtualBox/Vagrant
}
func keepMAC(ifName string, mac []byte) bool {
if len(mac) != 6 {
return false
}
base := strings.TrimRightFunc(ifName, unicode.IsNumber)
switch runtime.GOOS {
case "darwin":
switch base {
case "llw", "awdl", "utun", "bridge", "lo", "gif", "stf", "anpi", "ap":
return false
}
}
if mac[0] == 0x02 && mac[1] == 0x42 {
// Docker container.
return false
}
oui := [3]byte{mac[0], mac[1], mac[2]}
if ignoreWakeOUI[oui] {
return false
}
return true
}

View File

@@ -106,7 +106,7 @@ func (src *ServeConfig) Clone() *ServeConfig {
}
}
if dst.Services != nil {
dst.Services = map[string]*ServiceConfig{}
dst.Services = map[tailcfg.ServiceName]*ServiceConfig{}
for k, v := range src.Services {
if v == nil {
dst.Services[k] = nil
@@ -133,7 +133,7 @@ func (src *ServeConfig) Clone() *ServeConfig {
var _ServeConfigCloneNeedsRegeneration = ServeConfig(struct {
TCP map[uint16]*TCPPortHandler
Web map[HostPort]*WebServerConfig
Services map[string]*ServiceConfig
Services map[tailcfg.ServiceName]*ServiceConfig
AllowFunnel map[HostPort]bool
Foreground map[string]*ServeConfig
ETag string

View File

@@ -195,7 +195,7 @@ func (v ServeConfigView) Web() views.MapFn[HostPort, *WebServerConfig, WebServer
})
}
func (v ServeConfigView) Services() views.MapFn[string, *ServiceConfig, ServiceConfigView] {
func (v ServeConfigView) Services() views.MapFn[tailcfg.ServiceName, *ServiceConfig, ServiceConfigView] {
return views.MapFnOf(v.ж.Services, func(t *ServiceConfig) ServiceConfigView {
return t.View()
})
@@ -216,7 +216,7 @@ func (v ServeConfigView) ETag() string { return v.ж.ETag }
var _ServeConfigViewNeedsRegeneration = ServeConfig(struct {
TCP map[uint16]*TCPPortHandler
Web map[HostPort]*WebServerConfig
Services map[string]*ServiceConfig
Services map[tailcfg.ServiceName]*ServiceConfig
AllowFunnel map[HostPort]bool
Foreground map[string]*ServeConfig
ETag string

View File

@@ -10,19 +10,16 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
"github.com/kortschak/wol"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
"tailscale.com/ipn"
@@ -66,9 +63,6 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
req("GET /update"): handleC2NUpdateGet,
req("POST /update"): handleC2NUpdatePost,
// Wake-on-LAN.
req("POST /wol"): handleC2NWoL,
// Device posture.
req("GET /posture/identity"): handleC2NPostureIdentityGet,
@@ -82,6 +76,18 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
req("GET /vip-services"): handleC2NVIPServicesGet,
}
// RegisterC2N registers a new c2n handler for the given pattern.
//
// A pattern is like "GET /foo" (specific to an HTTP method) or "/foo" (all
// methods). It panics if the pattern is already registered.
func RegisterC2N(pattern string, h func(*LocalBackend, http.ResponseWriter, *http.Request)) {
k := req(pattern)
if _, ok := c2nHandlers[k]; ok {
panic(fmt.Sprintf("c2n: duplicate handler for %q", pattern))
}
c2nHandlers[k] = h
}
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
type methodAndPath struct {
@@ -503,55 +509,6 @@ func regularFileExists(path string) bool {
return err == nil && fi.Mode().IsRegular()
}
func handleC2NWoL(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
r.ParseForm()
var macs []net.HardwareAddr
for _, macStr := range r.Form["mac"] {
mac, err := net.ParseMAC(macStr)
if err != nil {
http.Error(w, "bad 'mac' param", http.StatusBadRequest)
return
}
macs = append(macs, mac)
}
var res struct {
SentTo []string
Errors []string
}
st := b.sys.NetMon.Get().InterfaceState()
if st == nil {
res.Errors = append(res.Errors, "no interface state")
writeJSON(w, &res)
return
}
var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
for _, mac := range macs {
for ifName, ips := range st.InterfaceIPs {
for _, ip := range ips {
if ip.Addr().IsLoopback() || ip.Addr().Is6() {
continue
}
local := &net.UDPAddr{
IP: ip.Addr().AsSlice(),
Port: 0,
}
remote := &net.UDPAddr{
IP: net.IPv4bcast,
Port: 0,
}
if err := wol.Wake(mac, password, local, remote); err != nil {
res.Errors = append(res.Errors, err.Error())
} else {
res.SentTo = append(res.SentTo, ifName)
}
break // one per interface is enough
}
}
}
sort.Strings(res.SentTo)
writeJSON(w, &res)
}
// handleC2NTLSCertStatus returns info about the last TLS certificate issued for the
// provided domain. This can be called by the controlplane to clean up DNS TXT
// records when they're no longer needed by LetsEncrypt.

View File

@@ -40,6 +40,7 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/store"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/bakedroots"
"tailscale.com/types/logger"
"tailscale.com/util/testenv"
"tailscale.com/version"
@@ -555,6 +556,7 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger
}
logf("requesting cert...")
traceACME(csr)
der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
if err != nil {
return nil, fmt.Errorf("CreateOrder: %v", err)
@@ -577,10 +579,10 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger
}
// certRequest generates a CSR for the given common name cn and optional SANs.
func certRequest(key crypto.Signer, cn string, ext []pkix.Extension, san ...string) ([]byte, error) {
func certRequest(key crypto.Signer, name string, ext []pkix.Extension) ([]byte, error) {
req := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cn},
DNSNames: san,
Subject: pkix.Name{CommonName: name},
DNSNames: []string{name},
ExtraExtensions: ext,
}
return x509.CreateCertificateRequest(rand.Reader, req, key)
@@ -665,7 +667,7 @@ func acmeClient(cs certStore) (*acme.Client, error) {
// validCertPEM reports whether the given certificate is valid for domain at now.
//
// If roots != nil, it is used instead of the system root pool. This is meant
// to support testing, and production code should pass roots == nil.
// to support testing; production code should pass roots == nil.
func validCertPEM(domain string, keyPEM, certPEM []byte, roots *x509.CertPool, now time.Time) bool {
if len(keyPEM) == 0 || len(certPEM) == 0 {
return false
@@ -688,15 +690,29 @@ func validCertPEM(domain string, keyPEM, certPEM []byte, roots *x509.CertPool, n
intermediates.AddCert(cert)
}
}
return validateLeaf(leaf, intermediates, domain, now, roots)
}
// validateLeaf is a helper for [validCertPEM].
//
// If called with roots == nil, it will use the system root pool as well as the
// baked-in roots. If non-nil, only those roots are used.
func validateLeaf(leaf *x509.Certificate, intermediates *x509.CertPool, domain string, now time.Time, roots *x509.CertPool) bool {
if leaf == nil {
return false
}
_, err = leaf.Verify(x509.VerifyOptions{
_, err := leaf.Verify(x509.VerifyOptions{
DNSName: domain,
CurrentTime: now,
Roots: roots,
Intermediates: intermediates,
})
if err != nil && roots == nil {
// If validation failed and they specified nil for roots (meaning to use
// the system roots), then give it another chance to validate using the
// binary's baked-in roots (LetsEncrypt). See tailscale/tailscale#14690.
return validateLeaf(leaf, intermediates, domain, now, bakedroots.Get())
}
return err == nil
}

View File

@@ -228,10 +228,11 @@ type LocalBackend struct {
// is never called.
getTCPHandlerForFunnelFlow func(srcAddr netip.AddrPort, dstPort uint16) (handler func(net.Conn))
filterAtomic atomic.Pointer[filter.Filter]
containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool]
shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool]
numClientStatusCalls atomic.Uint32
filterAtomic atomic.Pointer[filter.Filter]
containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool]
shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool]
shouldInterceptVIPServicesTCPPortAtomic syncs.AtomicValue[func(netip.AddrPort) bool]
numClientStatusCalls atomic.Uint32
// goTracker accounts for all goroutines started by LocalBacked, primarily
// for testing and graceful shutdown purposes.
@@ -317,8 +318,9 @@ type LocalBackend struct {
offlineAutoUpdateCancel func()
// ServeConfig fields. (also guarded by mu)
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
serveConfig ipn.ServeConfigView // or !Valid if none
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
serveConfig ipn.ServeConfigView // or !Valid if none
ipVIPServiceMap netmap.IPServiceMappings // map of VIPService IPs to their corresponding service names
webClient webClient
webClientListeners map[netip.AddrPort]*localListener // listeners for local web client traffic
@@ -523,6 +525,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
b.e.SetJailedFilter(noneFilter)
b.setTCPPortsIntercepted(nil)
b.setVIPServicesTCPPortsIntercepted(nil)
b.statusChanged = sync.NewCond(&b.statusLock)
b.e.SetStatusCallback(b.setWgengineStatus)
@@ -3362,10 +3365,7 @@ func (b *LocalBackend) clearMachineKeyLocked() error {
return nil
}
// setTCPPortsIntercepted populates b.shouldInterceptTCPPortAtomic with an
// efficient func for ShouldInterceptTCPPort to use, which is called on every
// incoming packet.
func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
func generateInterceptTCPPortFunc(ports []uint16) func(uint16) bool {
slices.Sort(ports)
ports = slices.Compact(ports)
var f func(uint16) bool
@@ -3396,7 +3396,63 @@ func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
}
}
}
b.shouldInterceptTCPPortAtomic.Store(f)
return f
}
// setTCPPortsIntercepted populates b.shouldInterceptTCPPortAtomic with an
// efficient func for ShouldInterceptTCPPort to use, which is called on every
// incoming packet.
func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
b.shouldInterceptTCPPortAtomic.Store(generateInterceptTCPPortFunc(ports))
}
func generateInterceptVIPServicesTCPPortFunc(svcAddrPorts map[netip.Addr]func(uint16) bool) func(netip.AddrPort) bool {
return func(ap netip.AddrPort) bool {
if f, ok := svcAddrPorts[ap.Addr()]; ok {
return f(ap.Port())
}
return false
}
}
// setVIPServicesTCPPortsIntercepted populates b.shouldInterceptVIPServicesTCPPortAtomic with an
// efficient func for ShouldInterceptTCPPort to use, which is called on every incoming packet.
func (b *LocalBackend) setVIPServicesTCPPortsIntercepted(svcPorts map[tailcfg.ServiceName][]uint16) {
b.mu.Lock()
defer b.mu.Unlock()
b.setVIPServicesTCPPortsInterceptedLocked(svcPorts)
}
func (b *LocalBackend) setVIPServicesTCPPortsInterceptedLocked(svcPorts map[tailcfg.ServiceName][]uint16) {
if len(svcPorts) == 0 {
b.shouldInterceptVIPServicesTCPPortAtomic.Store(func(netip.AddrPort) bool { return false })
return
}
nm := b.netMap
if nm == nil {
b.logf("can't set intercept function for Service TCP Ports, netMap is nil")
return
}
vipServiceIPMap := nm.GetVIPServiceIPMap()
if len(vipServiceIPMap) == 0 {
// No approved VIP Services
return
}
svcAddrPorts := make(map[netip.Addr]func(uint16) bool)
// Only set the intercept function if the service has been assigned a VIP.
for svcName, ports := range svcPorts {
addrs, ok := vipServiceIPMap[svcName]
if !ok {
continue
}
interceptFn := generateInterceptTCPPortFunc(ports)
for _, addr := range addrs {
svcAddrPorts[addr] = interceptFn
}
}
b.shouldInterceptVIPServicesTCPPortAtomic.Store(generateInterceptVIPServicesTCPPortFunc(svcAddrPorts))
}
// setAtomicValuesFromPrefsLocked populates sshAtomicBool, containsViaIPFuncAtomic,
@@ -3409,6 +3465,7 @@ func (b *LocalBackend) setAtomicValuesFromPrefsLocked(p ipn.PrefsView) {
if !p.Valid() {
b.containsViaIPFuncAtomic.Store(ipset.FalseContainsIPFunc())
b.setTCPPortsIntercepted(nil)
b.setVIPServicesTCPPortsInterceptedLocked(nil)
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
} else {
@@ -4159,6 +4216,11 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
}
}
// TODO(tailscale/corp#26001): Get handler for VIP services and Local IPs using
// the same function.
if handler := b.tcpHandlerForVIPService(dst, src); handler != nil {
return handler, opts
}
// Then handle external connections to the local IP.
if !b.isLocalIP(dst.Addr()) {
return nil, nil
@@ -4356,6 +4418,41 @@ func (b *LocalBackend) reconfigAppConnectorLocked(nm *netmap.NetworkMap, prefs i
b.appConnector.UpdateDomainsAndRoutes(domains, routes)
}
func (b *LocalBackend) readvertiseAppConnectorRoutes() {
// Note: we should never call b.appConnector methods while holding b.mu.
// This can lead to a deadlock, like
// https://github.com/tailscale/corp/issues/25965.
//
// Grab a copy of the field, since b.mu only guards access to the
// b.appConnector field itself.
b.mu.Lock()
appConnector := b.appConnector
b.mu.Unlock()
if appConnector == nil {
return
}
domainRoutes := appConnector.DomainRoutes()
if domainRoutes == nil {
return
}
// Re-advertise the stored routes, in case stored state got out of
// sync with previously advertised routes in prefs.
var prefixes []netip.Prefix
for _, ips := range domainRoutes {
for _, ip := range ips {
prefixes = append(prefixes, netip.PrefixFrom(ip, ip.BitLen()))
}
}
// Note: AdvertiseRoute will trim routes that are already
// advertised, so if everything is already being advertised this is
// a noop.
if err := b.AdvertiseRoute(prefixes...); err != nil {
b.logf("error advertising stored app connector routes: %v", err)
}
}
// authReconfig pushes a new configuration into wgengine, if engine
// updates are not currently blocked, based on the cached netmap and
// user prefs.
@@ -4434,6 +4531,7 @@ func (b *LocalBackend) authReconfig() {
}
b.initPeerAPIListener()
b.readvertiseAppConnectorRoutes()
}
// shouldUseOneCGNATRoute reports whether we should prefer to make one big
@@ -5676,6 +5774,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
netns.SetDisableBindConnToInterface(nm.HasCap(tailcfg.CapabilityDebugDisableBindConnToInterface))
b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs())
b.ipVIPServiceMap = nm.GetIPVIPServiceMap()
if nm == nil {
b.nodeByAddr = nil
@@ -5962,6 +6061,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
// b.mu must be held.
func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.PrefsView) {
handlePorts := make([]uint16, 0, 4)
var vipServicesPorts map[tailcfg.ServiceName][]uint16
if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() {
handlePorts = append(handlePorts, 22)
@@ -5985,6 +6085,20 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
}
handlePorts = append(handlePorts, servePorts...)
for svc, cfg := range b.serveConfig.Services().All() {
servicePorts := make([]uint16, 0, 3)
for port := range cfg.TCP().All() {
if port > 0 {
servicePorts = append(servicePorts, uint16(port))
}
}
if _, ok := vipServicesPorts[svc]; !ok {
mak.Set(&vipServicesPorts, svc, servicePorts)
} else {
mak.Set(&vipServicesPorts, svc, append(vipServicesPorts[svc], servicePorts...))
}
}
b.setServeProxyHandlersLocked()
// don't listen on netmap addresses if we're in userspace mode
@@ -5996,6 +6110,7 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
// Update funnel info in hostinfo and kick off control update if needed.
b.updateIngressLocked()
b.setTCPPortsIntercepted(handlePorts)
b.setVIPServicesTCPPortsInterceptedLocked(vipServicesPorts)
}
// updateIngressLocked updates the hostinfo.WireIngress and hostinfo.IngressEnabled fields and kicks off a Hostinfo
@@ -6854,6 +6969,12 @@ func (b *LocalBackend) ShouldInterceptTCPPort(port uint16) bool {
return b.shouldInterceptTCPPortAtomic.Load()(port)
}
// ShouldInterceptVIPServiceTCPPort reports whether the given TCP port number
// to a VIP service should be intercepted by Tailscaled and handled in-process.
func (b *LocalBackend) ShouldInterceptVIPServiceTCPPort(ap netip.AddrPort) bool {
return b.shouldInterceptVIPServicesTCPPortAtomic.Load()(ap)
}
// SwitchProfile switches to the profile with the given id.
// It will restart the backend on success.
// If the profile is not known, it returns an errProfileNotFound.
@@ -7155,17 +7276,17 @@ func (b *LocalBackend) DoSelfUpdate() {
// ObserveDNSResponse passes a DNS response from the PeerAPI DNS server to the
// App Connector to enable route discovery.
func (b *LocalBackend) ObserveDNSResponse(res []byte) {
func (b *LocalBackend) ObserveDNSResponse(res []byte) error {
var appConnector *appc.AppConnector
b.mu.Lock()
if b.appConnector == nil {
b.mu.Unlock()
return
return nil
}
appConnector = b.appConnector
b.mu.Unlock()
appConnector.ObserveDNSResponse(res)
return appConnector.ObserveDNSResponse(res)
}
// ErrDisallowedAutoRoute is returned by AdvertiseRoute when a route that is not allowed is requested.
@@ -7176,7 +7297,7 @@ var ErrDisallowedAutoRoute = errors.New("route is not allowed")
// If the route is disallowed, ErrDisallowedAutoRoute is returned.
func (b *LocalBackend) AdvertiseRoute(ipps ...netip.Prefix) error {
finalRoutes := b.Prefs().AdvertiseRoutes().AsSlice()
newRoutes := false
var newRoutes []netip.Prefix
for _, ipp := range ipps {
if !allowedAutoRoute(ipp) {
@@ -7192,13 +7313,14 @@ func (b *LocalBackend) AdvertiseRoute(ipps ...netip.Prefix) error {
}
finalRoutes = append(finalRoutes, ipp)
newRoutes = true
newRoutes = append(newRoutes, ipp)
}
if !newRoutes {
if len(newRoutes) == 0 {
return nil
}
b.logf("advertising new app connector routes: %v", newRoutes)
_, err := b.EditPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AdvertiseRoutes: finalRoutes,
@@ -7730,7 +7852,7 @@ func (b *LocalBackend) vipServiceHash(services []*tailcfg.VIPService) string {
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
// keyed by service name
var services map[string]*tailcfg.VIPService
var services map[tailcfg.ServiceName]*tailcfg.VIPService
if !b.serveConfig.Valid() {
return nil
}
@@ -7743,12 +7865,13 @@ func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcf
}
for _, s := range prefs.AdvertiseServices().All() {
if services == nil || services[s] == nil {
mak.Set(&services, s, &tailcfg.VIPService{
Name: s,
sn := tailcfg.ServiceName(s)
if services == nil || services[sn] == nil {
mak.Set(&services, sn, &tailcfg.VIPService{
Name: sn,
})
}
services[s].Active = true
services[sn].Active = true
}
return slicesx.MapValues(services)

View File

@@ -1372,7 +1372,9 @@ func TestObserveDNSResponse(t *testing.T) {
b := newTestBackend(t)
// ensure no error when no app connector is configured
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if err := b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
rc := &appctest.RouteCollector{}
if shouldStore {
@@ -1383,7 +1385,9 @@ func TestObserveDNSResponse(t *testing.T) {
b.appConnector.UpdateDomains([]string{"example.com"})
b.appConnector.Wait(context.Background())
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if err := b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
b.appConnector.Wait(context.Background())
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
if !slices.Equal(rc.Routes(), wantRoutes) {
@@ -1501,6 +1505,53 @@ func TestReconfigureAppConnector(t *testing.T) {
}
}
func TestBackfillAppConnectorRoutes(t *testing.T) {
// Create backend with an empty app connector.
b := newTestBackend(t)
if err := b.Start(ipn.Options{}); err != nil {
t.Fatal(err)
}
if _, err := b.EditPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AppConnector: ipn.AppConnectorPrefs{Advertise: true},
},
AppConnectorSet: true,
}); err != nil {
t.Fatal(err)
}
b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs)
// Smoke check that AdvertiseRoutes doesn't have the test IP.
ip := netip.MustParseAddr("1.2.3.4")
routes := b.Prefs().AdvertiseRoutes().AsSlice()
if slices.Contains(routes, netip.PrefixFrom(ip, ip.BitLen())) {
t.Fatalf("AdvertiseRoutes %v on a fresh backend already contains advertised route for %v", routes, ip)
}
// Store the test IP in profile data, but not in Prefs.AdvertiseRoutes.
b.ControlKnobs().AppCStoreRoutes.Store(true)
if err := b.storeRouteInfo(&appc.RouteInfo{
Domains: map[string][]netip.Addr{
"example.com": {ip},
},
}); err != nil {
t.Fatal(err)
}
// Mimic b.authReconfigure for the app connector bits.
b.mu.Lock()
b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs)
b.mu.Unlock()
b.readvertiseAppConnectorRoutes()
// Check that Prefs.AdvertiseRoutes got backfilled with routes stored in
// profile data.
routes = b.Prefs().AdvertiseRoutes().AsSlice()
if !slices.Contains(routes, netip.PrefixFrom(ip, ip.BitLen())) {
t.Fatalf("AdvertiseRoutes %v was not backfilled from stored app connector routes with %v", routes, ip)
}
}
func resolversEqual(t *testing.T, a, b []*dnstype.Resolver) bool {
if a == nil && b == nil {
return true
@@ -2615,6 +2666,150 @@ func TestOnTailnetDefaultAutoUpdate(t *testing.T) {
func TestTCPHandlerForDst(t *testing.T) {
b := newTestBackend(t)
tests := []struct {
desc string
dst string
intercept bool
}{
{
desc: "intercept port 80 (Web UI) on quad100 IPv4",
dst: "100.100.100.100:80",
intercept: true,
},
{
desc: "intercept port 80 (Web UI) on quad100 IPv6",
dst: "[fd7a:115c:a1e0::53]:80",
intercept: true,
},
{
desc: "don't intercept port 80 on local ip",
dst: "100.100.103.100:80",
intercept: false,
},
{
desc: "intercept port 8080 (Taildrive) on quad100 IPv4",
dst: "[fd7a:115c:a1e0::53]:8080",
intercept: true,
},
{
desc: "don't intercept port 8080 on local ip",
dst: "100.100.103.100:8080",
intercept: false,
},
{
desc: "don't intercept port 9080 on quad100 IPv4",
dst: "100.100.100.100:9080",
intercept: false,
},
{
desc: "don't intercept port 9080 on quad100 IPv6",
dst: "[fd7a:115c:a1e0::53]:9080",
intercept: false,
},
{
desc: "don't intercept port 9080 on local ip",
dst: "100.100.103.100:9080",
intercept: false,
},
}
for _, tt := range tests {
t.Run(tt.dst, func(t *testing.T) {
t.Log(tt.desc)
src := netip.MustParseAddrPort("100.100.102.100:51234")
h, _ := b.TCPHandlerForDst(src, netip.MustParseAddrPort(tt.dst))
if !tt.intercept && h != nil {
t.Error("intercepted traffic we shouldn't have")
} else if tt.intercept && h == nil {
t.Error("failed to intercept traffic we should have")
}
})
}
}
func TestTCPHandlerForDstWithVIPService(t *testing.T) {
b := newTestBackend(t)
svcIPMap := tailcfg.ServiceIPMappings{
"svc:foo": []netip.Addr{
netip.MustParseAddr("100.101.101.101"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:6565:6565"),
},
"svc:bar": []netip.Addr{
netip.MustParseAddr("100.99.99.99"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:626b:628b"),
},
"svc:baz": []netip.Addr{
netip.MustParseAddr("100.133.133.133"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:8585:8585"),
},
}
svcIPMapJSON, err := json.Marshal(svcIPMap)
if err != nil {
t.Fatal(err)
}
b.setNetMapLocked(
&netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "example.ts.net",
CapMap: tailcfg.NodeCapMap{
tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{tailcfg.RawMessage(svcIPMapJSON)},
},
}).View(),
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
tailcfg.UserID(1): {
LoginName: "someone@example.com",
DisplayName: "Some One",
ProfilePicURL: "https://example.com/photo.jpg",
},
},
},
)
err = b.setServeConfigLocked(
&ipn.ServeConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
882: {HTTP: true},
883: {HTTPS: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.example.ts.net:882": {
Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
},
},
"foo.example.ts.net:883": {
Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "test"},
},
},
},
},
"svc:bar": {
TCP: map[uint16]*ipn.TCPPortHandler{
990: {TCPForward: "127.0.0.1:8443"},
991: {TCPForward: "127.0.0.1:5432", TerminateTLS: "bar.test.ts.net"},
},
},
"svc:qux": {
TCP: map[uint16]*ipn.TCPPortHandler{
600: {HTTPS: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"qux.example.ts.net:600": {
Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "qux"},
},
},
},
},
},
},
"",
)
if err != nil {
t.Fatal(err)
}
tests := []struct {
desc string
@@ -2666,6 +2861,77 @@ func TestTCPHandlerForDst(t *testing.T) {
dst: "100.100.103.100:9080",
intercept: false,
},
// VIP service destinations
{
desc: "intercept port 882 (HTTP) on service foo IPv4",
dst: "100.101.101.101:882",
intercept: true,
},
{
desc: "intercept port 882 (HTTP) on service foo IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:882",
intercept: true,
},
{
desc: "intercept port 883 (HTTPS) on service foo IPv4",
dst: "100.101.101.101:883",
intercept: true,
},
{
desc: "intercept port 883 (HTTPS) on service foo IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:883",
intercept: true,
},
{
desc: "intercept port 990 (TCPForward) on service bar IPv4",
dst: "100.99.99.99:990",
intercept: true,
},
{
desc: "intercept port 990 (TCPForward) on service bar IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:990",
intercept: true,
},
{
desc: "intercept port 991 (TCPForward with TerminateTLS) on service bar IPv4",
dst: "100.99.99.99:990",
intercept: true,
},
{
desc: "intercept port 991 (TCPForward with TerminateTLS) on service bar IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:990",
intercept: true,
},
{
desc: "don't intercept port 4444 on service foo IPv4",
dst: "100.101.101.101:4444",
intercept: false,
},
{
desc: "don't intercept port 4444 on service foo IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:4444",
intercept: false,
},
{
desc: "don't intercept port 600 on unknown service IPv4",
dst: "100.22.22.22:883",
intercept: false,
},
{
desc: "don't intercept port 600 on unknown service IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:883",
intercept: false,
},
{
desc: "don't intercept port 600 (HTTPS) on service baz IPv4",
dst: "100.133.133.133:600",
intercept: false,
},
{
desc: "don't intercept port 600 (HTTPS) on service baz IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:8585:8585]:600",
intercept: false,
},
}
for _, tt := range tests {
@@ -4532,7 +4798,7 @@ func TestGetVIPServices(t *testing.T) {
"served-only",
[]string{},
&ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:abc": {Tun: true},
},
},
@@ -4547,7 +4813,7 @@ func TestGetVIPServices(t *testing.T) {
"served-and-advertised",
[]string{"svc:abc"},
&ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:abc": {Tun: true},
},
},
@@ -4563,7 +4829,7 @@ func TestGetVIPServices(t *testing.T) {
"served-and-advertised-different-service",
[]string{"svc:def"},
&ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:abc": {Tun: true},
},
},
@@ -4582,7 +4848,7 @@ func TestGetVIPServices(t *testing.T) {
"served-with-port-ranges-one-range-single",
[]string{},
&ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:abc": {TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTPS: true},
}},
@@ -4599,7 +4865,7 @@ func TestGetVIPServices(t *testing.T) {
"served-with-port-ranges-one-range-multiple",
[]string{},
&ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:abc": {TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTPS: true},
81: {HTTPS: true},
@@ -4618,7 +4884,7 @@ func TestGetVIPServices(t *testing.T) {
"served-with-port-ranges-multiple-ranges",
[]string{},
&ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:abc": {TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTPS: true},
81: {HTTPS: true},
@@ -4651,7 +4917,7 @@ func TestGetVIPServices(t *testing.T) {
}
got := lb.vipServicesFromPrefsLocked(prefs.View())
slices.SortFunc(got, func(a, b *tailcfg.VIPService) int {
return strings.Compare(a.Name, b.Name)
return strings.Compare(a.Name.String(), b.Name.String())
})
if !reflect.DeepEqual(tt.want, got) {
t.Logf("want:")

View File

@@ -20,13 +20,11 @@ import (
"path/filepath"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/kortschak/wol"
"golang.org/x/net/dns/dnsmessage"
"golang.org/x/net/http/httpguts"
"tailscale.com/drive"
@@ -226,6 +224,23 @@ type peerAPIHandler struct {
peerUser tailcfg.UserProfile // profile of peerNode
}
// PeerAPIHandler is the interface implemented by [peerAPIHandler] and needed by
// module features registered via tailscale.com/feature/*.
type PeerAPIHandler interface {
Peer() tailcfg.NodeView
PeerCaps() tailcfg.PeerCapMap
Self() tailcfg.NodeView
LocalBackend() *LocalBackend
IsSelfUntagged() bool // whether the peer is untagged and the same as this user
}
func (h *peerAPIHandler) IsSelfUntagged() bool {
return !h.selfNode.IsTagged() && !h.peerNode.IsTagged() && h.isSelf
}
func (h *peerAPIHandler) Peer() tailcfg.NodeView { return h.peerNode }
func (h *peerAPIHandler) Self() tailcfg.NodeView { return h.selfNode }
func (h *peerAPIHandler) LocalBackend() *LocalBackend { return h.ps.b }
func (h *peerAPIHandler) logf(format string, a ...any) {
h.ps.b.logf("peerapi: "+format, a...)
}
@@ -302,6 +317,20 @@ func peerAPIRequestShouldGetSecurityHeaders(r *http.Request) bool {
return false
}
// RegisterPeerAPIHandler registers a PeerAPI handler.
//
// The path should be of the form "/v0/foo".
//
// It panics if the path is already registered.
func RegisterPeerAPIHandler(path string, f func(PeerAPIHandler, http.ResponseWriter, *http.Request)) {
if _, ok := peerAPIHandlers[path]; ok {
panic(fmt.Sprintf("duplicate PeerAPI handler %q", path))
}
peerAPIHandlers[path] = f
}
var peerAPIHandlers = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){} // by URL.Path
func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h.validatePeerAPIRequest(r); err != nil {
metricInvalidRequests.Add(1)
@@ -346,10 +375,6 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case "/v0/dnsfwd":
h.handleServeDNSFwd(w, r)
return
case "/v0/wol":
metricWakeOnLANCalls.Add(1)
h.handleWakeOnLAN(w, r)
return
case "/v0/interfaces":
h.handleServeInterfaces(w, r)
return
@@ -364,6 +389,10 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handleServeIngress(w, r)
return
}
if ph, ok := peerAPIHandlers[r.URL.Path]; ok {
ph(h, w, r)
return
}
who := h.peerUser.DisplayName
fmt.Fprintf(w, `<html>
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -624,14 +653,6 @@ func (h *peerAPIHandler) canDebug() bool {
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityDebugPeer)
}
// canWakeOnLAN reports whether h can send a Wake-on-LAN packet from this node.
func (h *peerAPIHandler) canWakeOnLAN() bool {
if h.peerNode.UnsignedPeerAPIOnly() {
return false
}
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityWakeOnLAN)
}
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
// canIngress reports whether h can send ingress requests to this node.
@@ -640,10 +661,10 @@ func (h *peerAPIHandler) canIngress() bool {
}
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
return h.peerCaps().HasCapability(wantCap)
return h.PeerCaps().HasCapability(wantCap)
}
func (h *peerAPIHandler) peerCaps() tailcfg.PeerCapMap {
func (h *peerAPIHandler) PeerCaps() tailcfg.PeerCapMap {
return h.ps.b.PeerCaps(h.remoteAddr.Addr())
}
@@ -817,61 +838,6 @@ func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Reques
dh.ServeHTTP(w, r)
}
func (h *peerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request) {
if !h.canWakeOnLAN() {
http.Error(w, "no WoL access", http.StatusForbidden)
return
}
if r.Method != "POST" {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
macStr := r.FormValue("mac")
if macStr == "" {
http.Error(w, "missing 'mac' param", http.StatusBadRequest)
return
}
mac, err := net.ParseMAC(macStr)
if err != nil {
http.Error(w, "bad 'mac' param", http.StatusBadRequest)
return
}
var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
st := h.ps.b.sys.NetMon.Get().InterfaceState()
if st == nil {
http.Error(w, "failed to get interfaces state", http.StatusInternalServerError)
return
}
var res struct {
SentTo []string
Errors []string
}
for ifName, ips := range st.InterfaceIPs {
for _, ip := range ips {
if ip.Addr().IsLoopback() || ip.Addr().Is6() {
continue
}
local := &net.UDPAddr{
IP: ip.Addr().AsSlice(),
Port: 0,
}
remote := &net.UDPAddr{
IP: net.IPv4bcast,
Port: 0,
}
if err := wol.Wake(mac, password, local, remote); err != nil {
res.Errors = append(res.Errors, err.Error())
} else {
res.SentTo = append(res.SentTo, ifName)
}
break // one per interface is enough
}
}
sort.Strings(res.SentTo)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
func (h *peerAPIHandler) replyToDNSQueries() bool {
if h.isSelf {
// If the peer is owned by the same user, just allow it
@@ -966,7 +932,11 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
// instead to avoid re-parsing the DNS response for improved performance in
// the future.
if h.ps.b.OfferingAppConnector() {
h.ps.b.ObserveDNSResponse(res)
if err := h.ps.b.ObserveDNSResponse(res); err != nil {
h.logf("ObserveDNSResponse error: %v", err)
// This is not fatal, we probably just failed to parse the upstream
// response. Return it to the caller anyway.
}
}
if pretty {
@@ -1150,7 +1120,7 @@ func (h *peerAPIHandler) handleServeDrive(w http.ResponseWriter, r *http.Request
return
}
capsMap := h.peerCaps()
capsMap := h.PeerCaps()
driveCaps, ok := capsMap[tailcfg.PeerCapabilityTaildrive]
if !ok {
h.logf("taildrive: not permitted")
@@ -1274,8 +1244,7 @@ var (
metricInvalidRequests = clientmetric.NewCounter("peerapi_invalid_requests")
// Non-debug PeerAPI endpoints.
metricPutCalls = clientmetric.NewCounter("peerapi_put")
metricDNSCalls = clientmetric.NewCounter("peerapi_dns")
metricWakeOnLANCalls = clientmetric.NewCounter("peerapi_wol")
metricIngressCalls = clientmetric.NewCounter("peerapi_ingress")
metricPutCalls = clientmetric.NewCounter("peerapi_put")
metricDNSCalls = clientmetric.NewCounter("peerapi_dns")
metricIngressCalls = clientmetric.NewCounter("peerapi_ingress")
)

View File

@@ -54,8 +54,9 @@ var ErrETagMismatch = errors.New("etag mismatch")
var serveHTTPContextKey ctxkey.Key[*serveHTTPContext]
type serveHTTPContext struct {
SrcAddr netip.AddrPort
DestPort uint16
SrcAddr netip.AddrPort
ForVIPService tailcfg.ServiceName // "" means local
DestPort uint16
// provides funnel-specific context, nil if not funneled
Funnel *funnelFlow
@@ -275,6 +276,12 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
}
if config != nil {
if err := config.CheckValidServicesConfig(); err != nil {
return err
}
}
nm := b.netMap
if nm == nil {
return errors.New("netMap is nil")
@@ -432,6 +439,105 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
handler(c)
}
// tcpHandlerForVIPService returns a handler for a TCP connection to a VIP service
// that is being served via the ipn.ServeConfig. It returns nil if the destination
// address is not a VIP service or if the VIP service does not have a TCP handler set.
func (b *LocalBackend) tcpHandlerForVIPService(dstAddr, srcAddr netip.AddrPort) (handler func(net.Conn) error) {
b.mu.Lock()
sc := b.serveConfig
ipVIPServiceMap := b.ipVIPServiceMap
b.mu.Unlock()
if !sc.Valid() {
return nil
}
dport := dstAddr.Port()
dstSvc, ok := ipVIPServiceMap[dstAddr.Addr()]
if !ok {
return nil
}
tcph, ok := sc.FindServiceTCP(dstSvc, dstAddr.Port())
if !ok {
b.logf("The destination service doesn't have a TCP handler set.")
return nil
}
if tcph.HTTPS() || tcph.HTTP() {
hs := &http.Server{
Handler: http.HandlerFunc(b.serveWebHandler),
BaseContext: func(_ net.Listener) context.Context {
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
SrcAddr: srcAddr,
ForVIPService: dstSvc,
DestPort: dport,
})
},
}
if tcph.HTTPS() {
// TODO(kevinliang10): just leaving this TLS cert creation as if we don't have other
// hostnames, but for services this getTLSServeCetForPort will need a version that also take
// in the hostname. How to store the TLS cert is still being discussed.
hs.TLSConfig = &tls.Config{
GetCertificate: b.getTLSServeCertForPort(dport, dstSvc),
}
return func(c net.Conn) error {
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
}
}
return func(c net.Conn) error {
return hs.Serve(netutil.NewOneConnListener(c, nil))
}
}
if backDst := tcph.TCPForward(); backDst != "" {
return func(conn net.Conn) error {
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
cancel()
if err != nil {
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
return nil
}
defer backConn.Close()
if sni := tcph.TerminateTLS(); sni != "" {
conn = tls.Server(conn, &tls.Config{
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
pair, err := b.GetCertPEM(ctx, sni)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
if err != nil {
return nil, err
}
return &cert, nil
},
})
}
errc := make(chan error, 1)
go func() {
_, err := io.Copy(backConn, conn)
errc <- err
}()
go func() {
_, err := io.Copy(conn, backConn)
errc <- err
}()
return <-errc
}
}
return nil
}
// tcpHandlerForServe returns a handler for a TCP connection to be served via
// the ipn.ServeConfig. The funnelFlow can be nil if this is not a funneled
// connection.
@@ -462,7 +568,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
}
if tcph.HTTPS() {
hs.TLSConfig = &tls.Config{
GetCertificate: b.getTLSServeCertForPort(dport),
GetCertificate: b.getTLSServeCertForPort(dport, ""),
}
return func(c net.Conn) error {
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
@@ -542,7 +648,7 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
b.logf("[unexpected] localbackend: no serveHTTPContext in request")
return z, "", false
}
wsc, ok := b.webServerConfig(hostname, sctx.DestPort)
wsc, ok := b.webServerConfig(hostname, sctx.ForVIPService, sctx.DestPort)
if !ok {
return z, "", false
}
@@ -900,7 +1006,7 @@ func allNumeric(s string) bool {
return s != ""
}
func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebServerConfigView, ok bool) {
func (b *LocalBackend) webServerConfig(hostname string, forVIPService tailcfg.ServiceName, port uint16) (c ipn.WebServerConfigView, ok bool) {
key := ipn.HostPort(fmt.Sprintf("%s:%v", hostname, port))
b.mu.Lock()
@@ -909,15 +1015,18 @@ func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebS
if !b.serveConfig.Valid() {
return c, false
}
if forVIPService != "" {
return b.serveConfig.FindServiceWeb(forVIPService, key)
}
return b.serveConfig.FindWeb(key)
}
func (b *LocalBackend) getTLSServeCertForPort(port uint16) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService tailcfg.ServiceName) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if hi == nil || hi.ServerName == "" {
return nil, errors.New("no SNI ServerName")
}
_, ok := b.webServerConfig(hi.ServerName, port)
_, ok := b.webServerConfig(hi.ServerName, forVIPService, port)
if !ok {
return nil, errors.New("no webserver configured for name/port")
}

View File

@@ -296,6 +296,203 @@ func TestServeConfigForeground(t *testing.T) {
}
}
// TestServeConfigServices tests the side effects of setting the
// Services field in a ServeConfig. The Services field is a map
// of all services the current service host is serving. Unlike what we
// serve for node itself, there is no foreground and no local handlers
// for the services. So the only things we need to test are if the
// services configured are valid and if they correctly set intercept
// functions for netStack.
func TestServeConfigServices(t *testing.T) {
b := newTestBackend(t)
svcIPMap := tailcfg.ServiceIPMappings{
"svc:foo": []netip.Addr{
netip.MustParseAddr("100.101.101.101"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:6565:6565"),
},
"svc:bar": []netip.Addr{
netip.MustParseAddr("100.99.99.99"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:626b:628b"),
},
}
svcIPMapJSON, err := json.Marshal(svcIPMap)
if err != nil {
t.Fatal(err)
}
b.netMap = &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "example.ts.net",
CapMap: tailcfg.NodeCapMap{
tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{tailcfg.RawMessage(svcIPMapJSON)},
},
}).View(),
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
tailcfg.UserID(1): {
LoginName: "someone@example.com",
DisplayName: "Some One",
ProfilePicURL: "https://example.com/photo.jpg",
},
},
}
tests := []struct {
name string
conf *ipn.ServeConfig
expectedErr error
packetDstAddrPort []netip.AddrPort
intercepted bool
}{
{
name: "no-services",
conf: &ipn.ServeConfig{},
packetDstAddrPort: []netip.AddrPort{
netip.MustParseAddrPort("100.101.101.101:443"),
},
intercepted: false,
},
{
name: "one-incorrectly-configured-service",
conf: &ipn.ServeConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
},
Tun: true,
},
},
},
expectedErr: ipn.ErrServiceConfigHasBothTCPAndTun,
},
{
// one correctly configured service with packet should be intercepted
name: "one-service-intercept-packet",
conf: &ipn.ServeConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
},
},
},
},
packetDstAddrPort: []netip.AddrPort{
netip.MustParseAddrPort("100.101.101.101:80"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:80"),
},
intercepted: true,
},
{
// one correctly configured service with packet should not be intercepted
name: "one-service-not-intercept-packet",
conf: &ipn.ServeConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
},
},
},
},
packetDstAddrPort: []netip.AddrPort{
netip.MustParseAddrPort("100.99.99.99:80"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"),
netip.MustParseAddrPort("100.101.101.101:82"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:82"),
},
intercepted: false,
},
{
// multiple correctly configured service with packet should be intercepted
name: "multiple-service-intercept-packet",
conf: &ipn.ServeConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
},
},
"svc:bar": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
82: {HTTPS: true},
},
},
},
},
packetDstAddrPort: []netip.AddrPort{
netip.MustParseAddrPort("100.99.99.99:80"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"),
netip.MustParseAddrPort("100.101.101.101:81"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:81"),
},
intercepted: true,
},
{
// multiple correctly configured service with packet should not be intercepted
name: "multiple-service-not-intercept-packet",
conf: &ipn.ServeConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
},
},
"svc:bar": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
82: {HTTPS: true},
},
},
},
},
packetDstAddrPort: []netip.AddrPort{
// ips in capmap but port is not hosting service
netip.MustParseAddrPort("100.99.99.99:77"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:77"),
netip.MustParseAddrPort("100.101.101.101:85"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:85"),
// ips not in capmap
netip.MustParseAddrPort("100.102.102.102:80"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6666:6666]:80"),
},
intercepted: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := b.SetServeConfig(tt.conf, "")
if err != nil && tt.expectedErr != nil {
if !errors.Is(err, tt.expectedErr) {
t.Fatalf("expected error %v,\n got %v", tt.expectedErr, err)
}
return
}
if err != nil {
t.Fatal(err)
}
for _, addrPort := range tt.packetDstAddrPort {
if tt.intercepted != b.ShouldInterceptVIPServiceTCPPort(addrPort) {
if tt.intercepted {
t.Fatalf("expected packet to be intercepted")
} else {
t.Fatalf("expected packet not to be intercepted")
}
}
}
})
}
}
func TestServeConfigETag(t *testing.T) {
b := newTestBackend(t)

View File

@@ -55,9 +55,9 @@ type ServeConfig struct {
// keyed by mount point ("/", "/foo", etc)
Web map[HostPort]*WebServerConfig `json:",omitempty"`
// Services maps from service name to a ServiceConfig. Which describes the
// L3, L4, and L7 forwarding information for the service.
Services map[string]*ServiceConfig `json:",omitempty"`
// Services maps from service name (in the form "svc:dns-label") to a ServiceConfig.
// Which describes the L3, L4, and L7 forwarding information for the service.
Services map[tailcfg.ServiceName]*ServiceConfig `json:",omitempty"`
// AllowFunnel is the set of SNI:port values for which funnel
// traffic is allowed, from trusted ingress peers.
@@ -607,9 +607,34 @@ func (v ServeConfigView) Webs() iter.Seq2[HostPort, WebServerConfigView] {
}
}
}
for _, service := range v.Services().All() {
for k, v := range service.Web().All() {
if !yield(k, v) {
return
}
}
}
}
}
// FindServiceTCP return the TCPPortHandlerView for the given service name and port.
func (v ServeConfigView) FindServiceTCP(svcName tailcfg.ServiceName, port uint16) (res TCPPortHandlerView, ok bool) {
svcCfg, ok := v.Services().GetOk(svcName)
if !ok {
return res, ok
}
return svcCfg.TCP().GetOk(port)
}
func (v ServeConfigView) FindServiceWeb(svcName tailcfg.ServiceName, hp HostPort) (res WebServerConfigView, ok bool) {
if svcCfg, ok := v.Services().GetOk(svcName); ok {
if res, ok := svcCfg.Web().GetOk(hp); ok {
return res, ok
}
}
return res, ok
}
// FindTCP returns the first TCP that matches with the given port. It
// prefers a foreground match first followed by a background search if none
// existed.
@@ -662,6 +687,17 @@ func (v ServeConfigView) HasFunnelForTarget(target HostPort) bool {
return false
}
// CheckValidServicesConfig reports whether the ServeConfig has
// invalid service configurations.
func (sc *ServeConfig) CheckValidServicesConfig() error {
for svcName, service := range sc.Services {
if err := service.checkValidConfig(); err != nil {
return fmt.Errorf("invalid service configuration for %q: %w", svcName, err)
}
}
return nil
}
// ServicePortRange returns the list of tailcfg.ProtoPortRange that represents
// the proto/ports pairs that are being served by the service.
//
@@ -699,3 +735,17 @@ func (v ServiceConfigView) ServicePortRange() []tailcfg.ProtoPortRange {
}
return ranges
}
// ErrServiceConfigHasBothTCPAndTun signals that a service
// in Tun mode cannot also has TCP or Web handlers set.
var ErrServiceConfigHasBothTCPAndTun = errors.New("the VIP Service configuration can not set TUN at the same time as TCP or Web")
// checkValidConfig checks if the service configuration is valid.
// Currently, the only invalid configuration is when the service is in Tun mode
// and has TCP or Web handlers.
func (v *ServiceConfig) checkValidConfig() error {
if v.Tun && (len(v.TCP) > 0 || len(v.Web) > 0) {
return ErrServiceConfigHasBothTCPAndTun
}
return nil
}

View File

@@ -0,0 +1,149 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package bakedroots contains WebPKI CA roots we bake into the tailscaled binary,
// lest the system's CA roots be missing them (or entirely empty).
package bakedroots
import (
"crypto/x509"
"sync"
"tailscale.com/util/testenv"
)
// Get returns the baked-in roots.
//
// As of 2025-01-21, this includes only the LetsEncrypt ISRG Root X1 root.
func Get() *x509.CertPool {
roots.once.Do(func() {
roots.parsePEM(append(
[]byte(letsEncryptX1),
letsEncryptX2...,
))
})
return roots.p
}
// testingTB is a subset of testing.TB needed
// to verify the caller isn't in a parallel test.
type testingTB interface {
// Setenv panics if it's in a parallel test.
Setenv(k, v string)
}
// ResetForTest resets the cached roots for testing,
// optionally setting them to caPEM if non-nil.
func ResetForTest(tb testingTB, caPEM []byte) {
if !testenv.InTest() {
panic("not in test")
}
tb.Setenv("ASSERT_NOT_PARALLEL_TEST", "1") // panics if tb's Parallel was called
roots = rootsOnce{}
if caPEM != nil {
roots.once.Do(func() { roots.parsePEM(caPEM) })
}
}
var roots rootsOnce
type rootsOnce struct {
once sync.Once
p *x509.CertPool
}
func (r *rootsOnce) parsePEM(caPEM []byte) {
p := x509.NewCertPool()
if !p.AppendCertsFromPEM(caPEM) {
panic("bogus PEM")
}
r.p = p
}
/*
letsEncryptX1 is the LetsEncrypt X1 root:
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
82:10:cf:b0:d2:40:e3:59:44:63:e0:bb:63:82:8b:00
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = US, O = Internet Security Research Group, CN = ISRG Root X1
Validity
Not Before: Jun 4 11:04:38 2015 GMT
Not After : Jun 4 11:04:38 2035 GMT
Subject: C = US, O = Internet Security Research Group, CN = ISRG Root X1
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
RSA Public-Key: (4096 bit)
We bake it into the binary as a fallback verification root,
in case the system we're running on doesn't have it.
(Tailscale runs on some ancient devices.)
To test that this code is working on Debian/Ubuntu:
$ sudo mv /usr/share/ca-certificates/mozilla/ISRG_Root_X1.crt{,.old}
$ sudo update-ca-certificates
Then restart tailscaled. To also test dnsfallback's use of it, nuke
your /etc/resolv.conf and it should still start & run fine.
*/
const letsEncryptX1 = `
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
`
// letsEncryptX2 is the ISRG Root X2.
//
// Subject: O = Internet Security Research Group, CN = ISRG Root X2
// Key type: ECDSA P-384
// Validity: until 2035-09-04 (generated 2020-09-04)
const letsEncryptX2 = `
-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----
`

View File

@@ -0,0 +1,32 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package bakedroots
import (
"slices"
"testing"
)
func TestBakedInRoots(t *testing.T) {
ResetForTest(t, nil)
p := Get()
got := p.Subjects()
if len(got) != 2 {
t.Errorf("subjects = %v; want 2", len(got))
}
// TODO(bradfitz): is there a way to easily make this test prettier without
// writing a DER decoder? I'm not seeing how.
var name []string
for _, der := range got {
name = append(name, string(der))
}
want := []string{
"0O1\v0\t\x06\x03U\x04\x06\x13\x02US1)0'\x06\x03U\x04\n\x13 Internet Security Research Group1\x150\x13\x06\x03U\x04\x03\x13\fISRG Root X1",
"0O1\v0\t\x06\x03U\x04\x06\x13\x02US1)0'\x06\x03U\x04\n\x13 Internet Security Research Group1\x150\x13\x06\x03U\x04\x03\x13\fISRG Root X2",
}
if !slices.Equal(name, want) {
t.Errorf("subjects = %q; want %q", name, want)
}
}

View File

@@ -27,6 +27,7 @@ import (
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/net/bakedroots"
"tailscale.com/net/tlsdial/blockblame"
)
@@ -154,7 +155,7 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
// Always verify with our baked-in Let's Encrypt certificate,
// so we can log an informational message. This is useful for
// detecting SSL MiTM.
opts.Roots = bakedInRoots()
opts.Roots = bakedroots.Get()
_, bakedErr := cs.PeerCertificates[0].Verify(opts)
if debug() {
log.Printf("tlsdial(bake %q): %v", host, bakedErr)
@@ -233,7 +234,7 @@ func SetConfigExpectedCert(c *tls.Config, certDNSName string) {
if errSys == nil {
return nil
}
opts.Roots = bakedInRoots()
opts.Roots = bakedroots.Get()
_, err := certs[0].Verify(opts)
if debug() {
log.Printf("tlsdial(bake %q/%q): %v", c.ServerName, certDNSName, err)
@@ -260,84 +261,3 @@ func NewTransport() *http.Transport {
},
}
}
/*
letsEncryptX1 is the LetsEncrypt X1 root:
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
82:10:cf:b0:d2:40:e3:59:44:63:e0:bb:63:82:8b:00
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = US, O = Internet Security Research Group, CN = ISRG Root X1
Validity
Not Before: Jun 4 11:04:38 2015 GMT
Not After : Jun 4 11:04:38 2035 GMT
Subject: C = US, O = Internet Security Research Group, CN = ISRG Root X1
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
RSA Public-Key: (4096 bit)
We bake it into the binary as a fallback verification root,
in case the system we're running on doesn't have it.
(Tailscale runs on some ancient devices.)
To test that this code is working on Debian/Ubuntu:
$ sudo mv /usr/share/ca-certificates/mozilla/ISRG_Root_X1.crt{,.old}
$ sudo update-ca-certificates
Then restart tailscaled. To also test dnsfallback's use of it, nuke
your /etc/resolv.conf and it should still start & run fine.
*/
const letsEncryptX1 = `
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
`
var bakedInRootsOnce struct {
sync.Once
p *x509.CertPool
}
func bakedInRoots() *x509.CertPool {
bakedInRootsOnce.Do(func() {
p := x509.NewCertPool()
if !p.AppendCertsFromPEM([]byte(letsEncryptX1)) {
panic("bogus PEM")
}
bakedInRootsOnce.p = p
})
return bakedInRootsOnce.p
}

View File

@@ -4,37 +4,22 @@
package tlsdial
import (
"crypto/x509"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"sync/atomic"
"testing"
"tailscale.com/health"
"tailscale.com/net/bakedroots"
)
func resetOnce() {
rv := reflect.ValueOf(&bakedInRootsOnce).Elem()
rv.Set(reflect.Zero(rv.Type()))
}
func TestBakedInRoots(t *testing.T) {
resetOnce()
p := bakedInRoots()
got := p.Subjects()
if len(got) != 1 {
t.Errorf("subjects = %v; want 1", len(got))
}
}
func TestFallbackRootWorks(t *testing.T) {
defer resetOnce()
defer bakedroots.ResetForTest(t, nil)
const debug = false
if runtime.GOOS != "linux" {
@@ -69,14 +54,7 @@ func TestFallbackRootWorks(t *testing.T) {
if err != nil {
t.Fatal(err)
}
resetOnce()
bakedInRootsOnce.Do(func() {
p := x509.NewCertPool()
if !p.AppendCertsFromPEM(caPEM) {
t.Fatal("failed to add")
}
bakedInRootsOnce.p = p
})
bakedroots.ResetForTest(t, caPEM)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {

View File

@@ -14,11 +14,12 @@ import (
"time"
"github.com/tailscale/wireguard-go/tun"
"tailscale.com/feature"
"tailscale.com/types/logger"
)
// createTAP is non-nil on Linux.
var createTAP func(logf logger.Logf, tapName, bridgeName string) (tun.Device, error)
// CrateTAP is the hook set by feature/tap.
var CreateTAP feature.Hook[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.
@@ -29,7 +30,7 @@ func New(logf logger.Logf, tunName string) (tun.Device, string, error) {
if runtime.GOOS != "linux" {
return nil, "", errors.New("tap only works on Linux")
}
if createTAP == nil { // if the ts_omit_tap tag is used
if !CreateTAP.IsSet() { // if the ts_omit_tap tag is used
return nil, "", errors.New("tap is not supported in this build")
}
f := strings.Split(tunName, ":")
@@ -42,7 +43,7 @@ func New(logf logger.Logf, tunName string) (tun.Device, string, error) {
default:
return nil, "", errors.New("bogus tap argument")
}
dev, err = createTAP(logf, tapName, bridgeName)
dev, err = CreateTAP.Get()(logf, tapName, bridgeName)
} else {
dev, err = tun.CreateTUN(tunName, int(DefaultTUNMTU()))
}

View File

@@ -53,7 +53,8 @@ const PacketStartOffset = device.MessageTransportHeaderSize
// of a packet that can be injected into a tstun.Wrapper.
const MaxPacketSize = device.MaxContentSize
const tapDebug = false // for super verbose TAP debugging
// TAPDebug is whether super verbose TAP debugging is enabled.
const TAPDebug = false
var (
// ErrClosed is returned when attempting an operation on a closed Wrapper.
@@ -459,7 +460,7 @@ func (t *Wrapper) pollVector() {
return
}
n, err = reader(t.vectorBuffer[:], sizes, readOffset)
if t.isTAP && tapDebug {
if t.isTAP && TAPDebug {
s := fmt.Sprintf("% x", t.vectorBuffer[0][:])
for strings.HasSuffix(s, " 00") {
s = strings.TrimSuffix(s, " 00")
@@ -792,7 +793,9 @@ func (pc *peerConfigTable) outboundPacketIsJailed(p *packet.Parsed) bool {
return c.jailed
}
type setIPer interface {
// SetIPer is the interface expected to be implemented by the TAP implementation
// of tun.Device.
type SetIPer interface {
// SetIP sets the IP addresses of the TAP device.
SetIP(ipV4, ipV6 netip.Addr) error
}
@@ -800,7 +803,7 @@ type setIPer interface {
// 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 {
if sip, ok := t.tdev.(SetIPer); ok {
sip.SetIP(findV4(wcfg.Addresses), findV6(wcfg.Addresses))
}
}
@@ -874,12 +877,13 @@ func (t *Wrapper) filterPacketOutboundToWireGuard(p *packet.Parsed, pc *peerConf
return filter.Drop, gro
}
if filt.RunOut(p, t.filterFlags) != filter.Accept {
if resp, reason := filt.RunOut(p, t.filterFlags); resp != filter.Accept {
metricPacketOutDropFilter.Add(1)
// TODO(#14280): increment a t.metrics.outboundDroppedPacketsTotal here
// once we figure out & document what labels to use for multicast,
// link-local-unicast, IP fragments, etc. But they're not
// usermetric.ReasonACL.
if reason != "" {
t.metrics.outboundDroppedPacketsTotal.Add(usermetric.DropLabels{
Reason: reason,
}, 1)
}
return filter.Drop, gro
}

View File

@@ -27,6 +27,7 @@ import (
"tailscale.com/types/tkatype"
"tailscale.com/util/dnsname"
"tailscale.com/util/slicesx"
"tailscale.com/util/vizerror"
)
// CapabilityVersion represents the client's capability level. That
@@ -717,21 +718,6 @@ 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 {
@@ -897,16 +883,51 @@ type Hostinfo struct {
// require changes to Hostinfo.Equal.
}
// ServiceName is the name of a service, of the form `svc:dns-label`. Services
// represent some kind of application provided for users of the tailnet with a
// MagicDNS name and possibly dedicated IP addresses. Currently (2024-01-21),
// the only type of service is [VIPService].
// This is not related to the older [Service] used in [Hostinfo.Services].
type ServiceName string
// Validate validates if the service name is formatted correctly.
// We only allow valid DNS labels, since the expectation is that these will be
// used as parts of domain names. All errors are [vizerror.Error].
func (sn ServiceName) Validate() error {
bareName, ok := strings.CutPrefix(string(sn), "svc:")
if !ok {
return vizerror.Errorf("%q is not a valid service name: must start with 'svc:'", sn)
}
if bareName == "" {
return vizerror.Errorf("%q is not a valid service name: must not be empty after the 'svc:' prefix", sn)
}
return dnsname.ValidLabel(bareName)
}
// String implements [fmt.Stringer].
func (sn ServiceName) String() string {
return string(sn)
}
// WithoutPrefix is the name of the service without the `svc:` prefix, used for
// DNS names. If the name does not include the prefix (which means
// [ServiceName.Validate] would return an error) then it returns "".
func (sn ServiceName) WithoutPrefix() string {
bareName, ok := strings.CutPrefix(string(sn), "svc:")
if !ok {
return ""
}
return bareName
}
// VIPService represents a service created on a tailnet from the
// perspective of a node providing that service. These services
// have an virtual IP (VIP) address pair distinct from the node's IPs.
type VIPService struct {
// Name is the name of the service, of the form `svc:dns-label`.
// See CheckServiceName for a validation func.
// Name uniquely identifies a service on a particular tailnet,
// and so also corresponds uniquely to the pair of IP addresses
// belonging to the VIP service.
Name string
// Name is the name of the service. The Name uniquely identifies a service
// on a particular tailnet, and so also corresponds uniquely to the pair of
// IP addresses belonging to the VIP service.
Name ServiceName
// Ports specify which ProtoPorts are made available by this node
// on the service's IPs.
@@ -927,14 +948,6 @@ func (hi *Hostinfo) TailscaleSSHEnabled() bool {
func (v HostinfoView) TailscaleSSHEnabled() bool { return v.ж.TailscaleSSHEnabled() }
// TailscaleFunnelEnabled reports whether or not this node has explicitly
// enabled Funnel.
func (hi *Hostinfo) TailscaleFunnelEnabled() bool {
return hi != nil && hi.WireIngress
}
func (v HostinfoView) TailscaleFunnelEnabled() bool { return v.ж.TailscaleFunnelEnabled() }
// NetInfo contains information about the host's network state.
type NetInfo struct {
// MappingVariesByDestIP says whether the host's NAT mappings
@@ -2980,10 +2993,10 @@ type EarlyNoise struct {
// vs NodeKey)
const LBHeader = "Ts-Lb"
// ServiceIPMappings maps service names (strings that conform to
// [CheckServiceName]) to lists of IP addresses. This is used as the value of
// the [NodeAttrServiceHost] capability, to inform service hosts what IP
// addresses they need to listen on for each service that they are advertising.
// ServiceIPMappings maps ServiceName to lists of IP addresses. This is used
// as the value of the [NodeAttrServiceHost] capability, to inform service hosts
// what IP addresses they need to listen on for each service that they are
// advertising.
//
// This is of the form:
//
@@ -2996,4 +3009,4 @@ const LBHeader = "Ts-Lb"
// provided in AllowedIPs, but this lets the client know which services
// correspond to those IPs. Any services that don't correspond to a service
// this client is hosting can be ignored.
type ServiceIPMappings map[string][]netip.Addr
type ServiceIPMappings map[ServiceName][]netip.Addr

View File

@@ -29,6 +29,7 @@ import (
"tailscale.com/client/tailscale"
"tailscale.com/control/controlclient"
"tailscale.com/envknob"
_ "tailscale.com/feature/condregister"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"

View File

@@ -17,6 +17,7 @@ import (
_ "tailscale.com/derp/derphttp"
_ "tailscale.com/drive/driveimpl"
_ "tailscale.com/envknob"
_ "tailscale.com/feature/condregister"
_ "tailscale.com/health"
_ "tailscale.com/hostinfo"
_ "tailscale.com/ipn"

View File

@@ -17,6 +17,7 @@ import (
_ "tailscale.com/derp/derphttp"
_ "tailscale.com/drive/driveimpl"
_ "tailscale.com/envknob"
_ "tailscale.com/feature/condregister"
_ "tailscale.com/health"
_ "tailscale.com/hostinfo"
_ "tailscale.com/ipn"

View File

@@ -17,6 +17,7 @@ import (
_ "tailscale.com/derp/derphttp"
_ "tailscale.com/drive/driveimpl"
_ "tailscale.com/envknob"
_ "tailscale.com/feature/condregister"
_ "tailscale.com/health"
_ "tailscale.com/hostinfo"
_ "tailscale.com/ipn"

View File

@@ -17,6 +17,7 @@ import (
_ "tailscale.com/derp/derphttp"
_ "tailscale.com/drive/driveimpl"
_ "tailscale.com/envknob"
_ "tailscale.com/feature/condregister"
_ "tailscale.com/health"
_ "tailscale.com/hostinfo"
_ "tailscale.com/ipn"

View File

@@ -24,6 +24,7 @@ import (
_ "tailscale.com/derp/derphttp"
_ "tailscale.com/drive/driveimpl"
_ "tailscale.com/envknob"
_ "tailscale.com/feature/condregister"
_ "tailscale.com/health"
_ "tailscale.com/hostinfo"
_ "tailscale.com/ipn"
@@ -59,6 +60,7 @@ import (
_ "tailscale.com/util/osshare"
_ "tailscale.com/util/syspolicy"
_ "tailscale.com/util/winutil"
_ "tailscale.com/util/winutil/gp"
_ "tailscale.com/version"
_ "tailscale.com/version/distro"
_ "tailscale.com/wf"

View File

@@ -52,15 +52,15 @@ func Debugger(mux *http.ServeMux) *DebugHandler {
ret.KV("Version", version.Long())
ret.Handle("vars", "Metrics (Go)", expvar.Handler())
ret.Handle("varz", "Metrics (Prometheus)", http.HandlerFunc(promvarz.Handler))
// pprof.Index serves everything that runtime/pprof.Lookup finds:
// goroutine, threadcreate, heap, allocs, block, mutex
ret.Handle("pprof/", "pprof (index)", http.HandlerFunc(pprof.Index))
// the CPU profile handler is special because it responds
// streamily, unlike every other pprof handler. This means it's
// not made available through pprof.Index the way all the other
// pprof types are, you have to register the CPU profile handler
// separately. Use HandleSilent for that to not pollute the human
// debug list with a link that produces streaming line noise if
// you click it.
// But register the other ones from net/http/pprof directly:
ret.HandleSilent("pprof/cmdline", http.HandlerFunc(pprof.Cmdline))
ret.HandleSilent("pprof/profile", http.HandlerFunc(pprof.Profile))
ret.HandleSilent("pprof/symbol", http.HandlerFunc(pprof.Symbol))
ret.HandleSilent("pprof/trace", http.HandlerFunc(pprof.Trace))
ret.URL("/debug/pprof/goroutine?debug=1", "Goroutines (collapsed)")
ret.URL("/debug/pprof/goroutine?debug=2", "Goroutines (full)")
ret.Handle("gc", "force GC", http.HandlerFunc(gcHandler))

View File

@@ -101,6 +101,54 @@ func (nm *NetworkMap) GetAddresses() views.Slice[netip.Prefix] {
return nm.SelfNode.Addresses()
}
// GetVIPServiceIPMap returns a map of service names to the slice of
// VIP addresses that correspond to the service. The service names are
// with the prefix "svc:".
//
// TODO(tailscale/corp##25997): cache the result of decoding the capmap so that
// we don't have to decode it multiple times after each netmap update.
func (nm *NetworkMap) GetVIPServiceIPMap() tailcfg.ServiceIPMappings {
if nm == nil {
return nil
}
if !nm.SelfNode.Valid() {
return nil
}
ipMaps, err := tailcfg.UnmarshalNodeCapJSON[tailcfg.ServiceIPMappings](nm.SelfNode.CapMap().AsMap(), tailcfg.NodeAttrServiceHost)
if len(ipMaps) != 1 || err != nil {
return nil
}
return ipMaps[0]
}
// GetIPVIPServiceMap returns a map of VIP addresses to the service
// names that has the VIP address. The service names are with the
// prefix "svc:".
func (nm *NetworkMap) GetIPVIPServiceMap() IPServiceMappings {
var res IPServiceMappings
if nm == nil {
return res
}
if !nm.SelfNode.Valid() {
return res
}
serviceIPMap := nm.GetVIPServiceIPMap()
if serviceIPMap == nil {
return res
}
res = make(IPServiceMappings)
for svc, addrs := range serviceIPMap {
for _, addr := range addrs {
res[addr] = svc
}
}
return res
}
// AnyPeersAdvertiseRoutes reports whether any peer is advertising non-exit node routes.
func (nm *NetworkMap) AnyPeersAdvertiseRoutes() bool {
for _, p := range nm.Peers {
@@ -377,3 +425,19 @@ const (
_ WGConfigFlags = 1 << iota
AllowSubnetRoutes
)
// IPServiceMappings maps IP addresses to service names. This is the inverse of
// [tailcfg.ServiceIPMappings], and is used to inform track which service a VIP
// is associated with. This is set to b.ipVIPServiceMap every time the netmap is
// updated. This is used to reduce the cost for looking up the service name for
// the dst IP address in the netStack packet processing workflow.
//
// This is of the form:
//
// {
// "100.65.32.1": "svc:samba",
// "fd7a:115c:a1e0::1234": "svc:samba",
// "100.102.42.3": "svc:web",
// "fd7a:115c:a1e0::abcd": "svc:web",
// }
type IPServiceMappings map[netip.Addr]tailcfg.ServiceName

View File

@@ -270,7 +270,7 @@ func (c *AggregateCounter) UnregisterAll() {
// a sum of expvar variables registered with it.
func NewAggregateCounter(name string) *AggregateCounter {
c := &AggregateCounter{counters: set.Set[*expvar.Int]{}}
NewGaugeFunc(name, c.Value)
NewCounterFunc(name, c.Value)
return c
}

View File

@@ -94,7 +94,8 @@ func (f FQDN) Contains(other FQDN) bool {
return strings.HasSuffix(other.WithTrailingDot(), cmp)
}
// ValidLabel reports whether label is a valid DNS label.
// ValidLabel reports whether label is a valid DNS label. All errors are
// [vizerror.Error].
func ValidLabel(label string) error {
if len(label) == 0 {
return vizerror.New("empty DNS label")

View File

@@ -7,7 +7,11 @@ package execqueue
import (
"context"
"errors"
"expvar"
"fmt"
"sync"
"sync/atomic"
"time"
)
type ExecQueue struct {
@@ -16,9 +20,36 @@ type ExecQueue struct {
inFlight bool // whether a goroutine is running q.run
doneWaiter chan struct{} // non-nil if waiter is waiting, then closed
queue []func()
// metrics follow
metricsRegisterOnce sync.Once
metricInserts expvar.Int
metricRemovals expvar.Int
metricQueueLastDrain expvar.Int // unix millis
}
// This is extremely silly but is for debugging
var metricsCounter atomic.Int64
// registerMetrics registers the queue's metrics with expvar, using a unique name.
func (q *ExecQueue) registerMetrics() {
q.metricsRegisterOnce.Do(func() {
m := new(expvar.Map).Init()
m.Set("inserts", &q.metricInserts)
m.Set("removals", &q.metricRemovals)
m.Set("length", expvar.Func(func() any {
return q.metricInserts.Value() - q.metricRemovals.Value()
}))
m.Set("last_drain", &q.metricQueueLastDrain)
name := fmt.Sprintf("execqueue-%d", metricsCounter.Add(1))
expvar.Publish(name, m)
})
}
func (q *ExecQueue) Add(f func()) {
q.registerMetrics()
q.mu.Lock()
defer q.mu.Unlock()
if q.closed {
@@ -26,6 +57,7 @@ func (q *ExecQueue) Add(f func()) {
}
if q.inFlight {
q.queue = append(q.queue, f)
q.metricInserts.Add(1)
} else {
q.inFlight = true
go q.run(f)
@@ -35,6 +67,8 @@ func (q *ExecQueue) Add(f func()) {
// RunSync waits for the queue to be drained and then synchronously runs f.
// It returns an error if the queue is closed before f is run or ctx expires.
func (q *ExecQueue) RunSync(ctx context.Context, f func()) error {
q.registerMetrics()
for {
if err := q.Wait(ctx); err != nil {
return err
@@ -61,11 +95,13 @@ func (q *ExecQueue) run(f func()) {
f := q.queue[0]
q.queue[0] = nil
q.queue = q.queue[1:]
q.metricRemovals.Add(1)
q.mu.Unlock()
f()
q.mu.Lock()
}
q.inFlight = false
q.metricQueueLastDrain.Set(int64(time.Now().UnixMilli()))
q.queue = nil
if q.doneWaiter != nil {
close(q.doneWaiter)
@@ -76,6 +112,8 @@ func (q *ExecQueue) run(f func()) {
// Shutdown asynchronously signals the queue to stop.
func (q *ExecQueue) Shutdown() {
q.registerMetrics()
q.mu.Lock()
defer q.mu.Unlock()
q.closed = true
@@ -83,6 +121,8 @@ func (q *ExecQueue) Shutdown() {
// Wait waits for the queue to be empty.
func (q *ExecQueue) Wait(ctx context.Context) error {
q.registerMetrics()
q.mu.Lock()
waitCh := q.doneWaiter
if q.inFlight && waitCh == nil {

View File

@@ -95,6 +95,17 @@ func Filter[S ~[]T, T any](dst, src S, fn func(T) bool) S {
return dst
}
// AppendNonzero appends all non-zero elements of src to dst.
func AppendNonzero[S ~[]T, T comparable](dst, src S) S {
var zero T
for _, v := range src {
if v != zero {
dst = append(dst, v)
}
}
return dst
}
// AppendMatching appends elements in ps to dst if f(x) is true.
func AppendMatching[T any](dst, ps []T, f func(T) bool) []T {
for _, p := range ps {

View File

@@ -137,6 +137,19 @@ func TestFilterNoAllocations(t *testing.T) {
}
}
func TestAppendNonzero(t *testing.T) {
v := []string{"one", "two", "", "four"}
got := AppendNonzero(nil, v)
want := []string{"one", "two", "four"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v; want %v", got, want)
}
got = AppendNonzero(v[:0], v)
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestAppendMatching(t *testing.T) {
v := []string{"one", "two", "three", "four"}
got := AppendMatching(v[:0], v, func(s string) bool { return len(s) > 3 })

View File

@@ -289,7 +289,7 @@ func newSettingMetric(key setting.Key, scope setting.Scope, suffix string, typ c
}
func newMetric(nameParts []string, typ clientmetric.Type) metric {
name := strings.Join(slicesx.Filter([]string{internal.OS(), "syspolicy"}, nameParts, isNonEmpty), "_")
name := strings.Join(slicesx.AppendNonzero([]string{internal.OS(), "syspolicy"}, nameParts), "_")
switch {
case !ShouldReport():
return &funcMetric{name: name, typ: typ}
@@ -304,8 +304,6 @@ func newMetric(nameParts []string, typ clientmetric.Type) metric {
}
}
func isNonEmpty(s string) bool { return s != "" }
func metricScopeName(scope setting.Scope) string {
switch scope {
case setting.DeviceSetting:

View File

@@ -12,6 +12,7 @@ import (
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"tailscale.com/util/set"
"tailscale.com/util/syspolicy/internal/loggerx"
"tailscale.com/util/syspolicy/setting"
"tailscale.com/util/winutil/gp"
)
@@ -29,6 +30,18 @@ var (
_ Expirable = (*PlatformPolicyStore)(nil)
)
// lockableCloser is a [Lockable] that can also be closed.
// It is implemented by [gp.PolicyLock] and [optionalPolicyLock].
type lockableCloser interface {
Lockable
Close() error
}
var (
_ lockableCloser = (*gp.PolicyLock)(nil)
_ lockableCloser = (*optionalPolicyLock)(nil)
)
// PlatformPolicyStore implements [Store] by providing read access to
// Registry-based Tailscale policies, such as those configured via Group Policy or MDM.
// For better performance and consistency, it is recommended to lock it when
@@ -55,7 +68,7 @@ type PlatformPolicyStore struct {
// they are being read.
//
// When both policyLock and mu need to be taken, mu must be taken before policyLock.
policyLock *gp.PolicyLock
policyLock lockableCloser
mu sync.Mutex
tsKeys []registry.Key // or nil if the [PlatformPolicyStore] hasn't been locked.
@@ -108,7 +121,7 @@ func newPlatformPolicyStore(scope gp.Scope, softwareKey registry.Key, policyLock
scope: scope,
softwareKey: softwareKey,
done: make(chan struct{}),
policyLock: policyLock,
policyLock: &optionalPolicyLock{PolicyLock: policyLock},
}
}
@@ -448,3 +461,68 @@ func tailscaleKeyNamesFor(scope gp.Scope) []string {
panic("unreachable")
}
}
type gpLockState int
const (
gpUnlocked = gpLockState(iota)
gpLocked
gpLockRestricted // the lock could not be acquired due to a restriction in place
)
// optionalPolicyLock is a wrapper around [gp.PolicyLock] that locks
// and unlocks the underlying [gp.PolicyLock].
//
// If the [gp.PolicyLock.Lock] returns [gp.ErrLockRestricted], the error is ignored,
// and calling [optionalPolicyLock.Unlock] is a no-op.
//
// The underlying GP lock is kinda optional: it is safe to read policy settings
// from the Registry without acquiring it, but it is recommended to lock it anyway
// when reading multiple policy settings to avoid potentially inconsistent results.
//
// It is not safe for concurrent use.
type optionalPolicyLock struct {
*gp.PolicyLock
state gpLockState
}
// Lock acquires the underlying [gp.PolicyLock], returning an error on failure.
// If the lock cannot be acquired due to a restriction in place
// (e.g., attempting to acquire a lock while the service is starting),
// the lock is considered to be held, the method returns nil, and a subsequent
// call to [Unlock] is a no-op.
// It is a runtime error to call Lock when the lock is already held.
func (o *optionalPolicyLock) Lock() error {
if o.state != gpUnlocked {
panic("already locked")
}
switch err := o.PolicyLock.Lock(); err {
case nil:
o.state = gpLocked
return nil
case gp.ErrLockRestricted:
loggerx.Errorf("GP lock not acquired: %v", err)
o.state = gpLockRestricted
return nil
default:
return err
}
}
// Unlock releases the underlying [gp.PolicyLock], if it was previously acquired.
// It is a runtime error to call Unlock when the lock is not held.
func (o *optionalPolicyLock) Unlock() {
switch o.state {
case gpLocked:
o.PolicyLock.Unlock()
case gpLockRestricted:
// The GP lock wasn't acquired due to a restriction in place
// when [optionalPolicyLock.Lock] was called. Unlock is a no-op.
case gpUnlocked:
panic("not locked")
default:
panic("unreachable")
}
o.state = gpUnlocked
}

View File

@@ -28,6 +28,22 @@ const (
// ReasonACL means that the packet was not permitted by ACL.
ReasonACL DropReason = "acl"
// ReasonMulticast means that the packet was dropped because it was a multicast packet.
ReasonMulticast DropReason = "multicast"
// ReasonLinkLocalUnicast means that the packet was dropped because it was a link-local unicast packet.
ReasonLinkLocalUnicast DropReason = "link_local_unicast"
// ReasonTooShort means that the packet was dropped because it was a bad packet,
// this could be due to a short packet.
ReasonTooShort DropReason = "too_short"
// ReasonFragment means that the packet was dropped because it was an IP fragment.
ReasonFragment DropReason = "fragment"
// ReasonUnknownProtocol means that the packet was dropped because it was an unknown protocol.
ReasonUnknownProtocol DropReason = "unknown_protocol"
// ReasonError means that the packet was dropped because of an error.
ReasonError DropReason = "error"
)

View File

@@ -48,10 +48,35 @@ type policyLockResult struct {
}
var (
// ErrInvalidLockState is returned by (*PolicyLock).Lock if the lock has a zero value or has already been closed.
// ErrInvalidLockState is returned by [PolicyLock.Lock] if the lock has a zero value or has already been closed.
ErrInvalidLockState = errors.New("the lock has not been created or has already been closed")
// ErrLockRestricted is returned by [PolicyLock.Lock] if the lock cannot be acquired due to a restriction in place,
// such as when [RestrictPolicyLocks] has been called.
ErrLockRestricted = errors.New("the lock cannot be acquired due to a restriction in place")
)
var policyLockRestricted atomic.Int32
// RestrictPolicyLocks forces all [PolicyLock.Lock] calls to return [ErrLockRestricted]
// until the returned function is called to remove the restriction.
//
// It is safe to call the returned function multiple times, but the restriction will only
// be removed once. If [RestrictPolicyLocks] is called multiple times, each call must be
// matched by a corresponding call to the returned function to fully remove the restrictions.
//
// It is primarily used to prevent certain deadlocks, such as when tailscaled attempts to acquire
// a policy lock during startup. If the service starts due to Tailscale being installed by GPSI,
// the write lock will be held by the Group Policy service throughout the installation,
// preventing tailscaled from acquiring the read lock. Since Group Policy waits for the installation
// to complete, and therefore for tailscaled to start, before releasing the write lock, this scenario
// would result in a deadlock. See tailscale/tailscale#14416 for more information.
func RestrictPolicyLocks() (removeRestriction func()) {
policyLockRestricted.Add(1)
return sync.OnceFunc(func() {
policyLockRestricted.Add(-1)
})
}
// NewMachinePolicyLock creates a PolicyLock that facilitates pausing the
// application of computer policy. To avoid deadlocks when acquiring both
// machine and user locks, acquire the user lock before the machine lock.
@@ -103,13 +128,18 @@ func NewUserPolicyLock(token windows.Token) (*PolicyLock, error) {
}
// Lock locks l.
// It returns ErrNotInitialized if l has a zero value or has already been closed,
// or an Errno if the underlying Group Policy lock cannot be acquired.
// It returns [ErrInvalidLockState] if l has a zero value or has already been closed,
// [ErrLockRestricted] if the lock cannot be acquired due to a restriction in place,
// or a [syscall.Errno] if the underlying Group Policy lock cannot be acquired.
//
// As a special case, it fails with windows.ERROR_ACCESS_DENIED
// As a special case, it fails with [windows.ERROR_ACCESS_DENIED]
// if l is a user policy lock, and the corresponding user is not logged in
// interactively at the time of the call.
func (l *PolicyLock) Lock() error {
if policyLockRestricted.Load() > 0 {
return ErrLockRestricted
}
l.mu.Lock()
defer l.mu.Unlock()
if l.lockCnt.Add(2)&1 == 0 {

View File

@@ -24,6 +24,7 @@ import (
"tailscale.com/types/views"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
"tailscale.com/util/usermetric"
"tailscale.com/wgengine/filter/filtertype"
)
@@ -410,7 +411,7 @@ func (f *Filter) ShieldsUp() bool { return f.shieldsUp }
// Tailscale peer.
func (f *Filter) RunIn(q *packet.Parsed, rf RunFlags) Response {
dir := in
r := f.pre(q, rf, dir)
r, _ := f.pre(q, rf, dir)
if r == Accept || r == Drop {
// already logged
return r
@@ -431,16 +432,16 @@ func (f *Filter) RunIn(q *packet.Parsed, rf RunFlags) Response {
// RunOut determines whether this node is allowed to send q to a
// Tailscale peer.
func (f *Filter) RunOut(q *packet.Parsed, rf RunFlags) Response {
func (f *Filter) RunOut(q *packet.Parsed, rf RunFlags) (Response, usermetric.DropReason) {
dir := out
r := f.pre(q, rf, dir)
r, reason := f.pre(q, rf, dir)
if r == Accept || r == Drop {
// already logged
return r
return r, reason
}
r, why := f.runOut(q)
f.logRateLimit(rf, q, dir, r, why)
return r
return r, ""
}
var unknownProtoStringCache sync.Map // ipproto.Proto -> string
@@ -610,33 +611,38 @@ var gcpDNSAddr = netaddr.IPv4(169, 254, 169, 254)
// pre runs the direction-agnostic filter logic. dir is only used for
// logging.
func (f *Filter) pre(q *packet.Parsed, rf RunFlags, dir direction) Response {
func (f *Filter) pre(q *packet.Parsed, rf RunFlags, dir direction) (Response, usermetric.DropReason) {
if len(q.Buffer()) == 0 {
// wireguard keepalive packet, always permit.
return Accept
return Accept, ""
}
if len(q.Buffer()) < 20 {
f.logRateLimit(rf, q, dir, Drop, "too short")
return Drop
return Drop, usermetric.ReasonTooShort
}
if q.IPProto == ipproto.Unknown {
f.logRateLimit(rf, q, dir, Drop, "unknown proto")
return Drop, usermetric.ReasonUnknownProtocol
}
if q.Dst.Addr().IsMulticast() {
f.logRateLimit(rf, q, dir, Drop, "multicast")
return Drop
return Drop, usermetric.ReasonMulticast
}
if q.Dst.Addr().IsLinkLocalUnicast() && q.Dst.Addr() != gcpDNSAddr {
f.logRateLimit(rf, q, dir, Drop, "link-local-unicast")
return Drop
return Drop, usermetric.ReasonLinkLocalUnicast
}
if q.IPProto == ipproto.Fragment {
// Fragments after the first always need to be passed through.
// Very small fragments are considered Junk by Parsed.
f.logRateLimit(rf, q, dir, Accept, "fragment")
return Accept
return Accept, ""
}
return noVerdict
return noVerdict, ""
}
// loggingAllowed reports whether p can appear in logs at all.

View File

@@ -30,6 +30,7 @@ import (
"tailscale.com/types/views"
"tailscale.com/util/must"
"tailscale.com/util/slicesx"
"tailscale.com/util/usermetric"
"tailscale.com/wgengine/filter/filtertype"
)
@@ -211,7 +212,7 @@ func TestUDPState(t *testing.T) {
t.Fatalf("incoming initial packet not dropped, got=%v: %v", got, a4)
}
// We talk to that peer
if got := acl.RunOut(&b4, flags); got != Accept {
if got, _ := acl.RunOut(&b4, flags); got != Accept {
t.Fatalf("outbound packet didn't egress, got=%v: %v", got, b4)
}
// Now, the same packet as before is allowed back.
@@ -227,7 +228,7 @@ func TestUDPState(t *testing.T) {
t.Fatalf("incoming initial packet not dropped: %v", a4)
}
// We talk to that peer
if got := acl.RunOut(&b6, flags); got != Accept {
if got, _ := acl.RunOut(&b6, flags); got != Accept {
t.Fatalf("outbound packet didn't egress: %v", b4)
}
// Now, the same packet as before is allowed back.
@@ -382,25 +383,27 @@ func BenchmarkFilter(b *testing.B) {
func TestPreFilter(t *testing.T) {
packets := []struct {
desc string
want Response
b []byte
desc string
want Response
wantReason usermetric.DropReason
b []byte
}{
{"empty", Accept, []byte{}},
{"short", Drop, []byte("short")},
{"junk", Drop, raw4default(ipproto.Unknown, 10)},
{"fragment", Accept, raw4default(ipproto.Fragment, 40)},
{"tcp", noVerdict, raw4default(ipproto.TCP, 0)},
{"udp", noVerdict, raw4default(ipproto.UDP, 0)},
{"icmp", noVerdict, raw4default(ipproto.ICMPv4, 0)},
{"empty", Accept, "", []byte{}},
{"short", Drop, usermetric.ReasonTooShort, []byte("short")},
{"short-junk", Drop, usermetric.ReasonTooShort, raw4default(ipproto.Unknown, 10)},
{"long-junk", Drop, usermetric.ReasonUnknownProtocol, raw4default(ipproto.Unknown, 21)},
{"fragment", Accept, "", raw4default(ipproto.Fragment, 40)},
{"tcp", noVerdict, "", raw4default(ipproto.TCP, 0)},
{"udp", noVerdict, "", raw4default(ipproto.UDP, 0)},
{"icmp", noVerdict, "", raw4default(ipproto.ICMPv4, 0)},
}
f := NewAllowNone(t.Logf, &netipx.IPSet{})
for _, testPacket := range packets {
p := &packet.Parsed{}
p.Decode(testPacket.b)
got := f.pre(p, LogDrops|LogAccepts, in)
if got != testPacket.want {
t.Errorf("%q got=%v want=%v packet:\n%s", testPacket.desc, got, testPacket.want, packet.Hexdump(testPacket.b))
got, gotReason := f.pre(p, LogDrops|LogAccepts, in)
if got != testPacket.want || gotReason != testPacket.wantReason {
t.Errorf("%q got=%v want=%v gotReason=%s wantReason=%s packet:\n%s", testPacket.desc, got, testPacket.want, gotReason, testPacket.wantReason, packet.Hexdump(testPacket.b))
}
}
}

View File

@@ -632,7 +632,7 @@ func (c *Conn) runDerpReader(ctx context.Context, regionID int, dc *derphttp.Cli
// Do nothing.
case derp.PeerGoneReasonNotHere:
metricRecvDiscoDERPPeerNotHere.Add(1)
c.logf("[unexpected] magicsock: derp-%d does not know about peer %s, removing route",
c.logf("magicsock: derp-%d does not know about peer %s, removing route",
regionID, key.NodePublic(m.Peer).ShortString())
default:
metricRecvDiscoDERPPeerGoneUnknown.Add(1)

View File

@@ -50,6 +50,7 @@ import (
"tailscale.com/types/netmap"
"tailscale.com/types/nettype"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
"tailscale.com/version"
"tailscale.com/wgengine"
"tailscale.com/wgengine/filter"
@@ -200,6 +201,8 @@ type Impl struct {
// updates.
atomicIsLocalIPFunc syncs.AtomicValue[func(netip.Addr) bool]
atomicIsVIPServiceIPFunc syncs.AtomicValue[func(netip.Addr) bool]
// forwardDialFunc, if non-nil, is the net.Dialer.DialContext-style
// function that is used to make outgoing connections when forwarding a
// TCP connection to another host (e.g. in subnet router mode).
@@ -387,6 +390,7 @@ func Create(logf logger.Logf, tundev *tstun.Wrapper, e wgengine.Engine, mc *magi
}
ns.ctx, ns.ctxCancel = context.WithCancel(context.Background())
ns.atomicIsLocalIPFunc.Store(ipset.FalseContainsIPFunc())
ns.atomicIsVIPServiceIPFunc.Store(ipset.FalseContainsIPFunc())
ns.tundev.PostFilterPacketInboundFromWireGuard = ns.injectInbound
ns.tundev.PreFilterPacketOutboundToWireGuardNetstackIntercept = ns.handleLocalPackets
stacksForMetrics.Store(ns, struct{}{})
@@ -532,7 +536,7 @@ func (ns *Impl) wrapTCPProtocolHandler(h protocolHandlerFunc) protocolHandlerFun
// Dynamically reconfigure ns's subnet addresses as needed for
// outbound traffic.
if !ns.isLocalIP(localIP) {
if !ns.isLocalIP(localIP) && !ns.isVIPServiceIP(localIP) {
ns.addSubnetAddress(localIP)
}
@@ -621,10 +625,17 @@ var v4broadcast = netaddr.IPv4(255, 255, 255, 255)
func (ns *Impl) UpdateNetstackIPs(nm *netmap.NetworkMap) {
var selfNode tailcfg.NodeView
if nm != nil {
vipServiceIPMap := nm.GetVIPServiceIPMap()
serviceAddrSet := set.Set[netip.Addr]{}
for _, addrs := range vipServiceIPMap {
serviceAddrSet.AddSlice(addrs)
}
ns.atomicIsLocalIPFunc.Store(ipset.NewContainsIPFunc(nm.GetAddresses()))
ns.atomicIsVIPServiceIPFunc.Store(serviceAddrSet.Contains)
selfNode = nm.SelfNode
} else {
ns.atomicIsLocalIPFunc.Store(ipset.FalseContainsIPFunc())
ns.atomicIsVIPServiceIPFunc.Store(ipset.FalseContainsIPFunc())
}
oldPfx := make(map[netip.Prefix]bool)
@@ -952,6 +963,12 @@ func (ns *Impl) isLocalIP(ip netip.Addr) bool {
return ns.atomicIsLocalIPFunc.Load()(ip)
}
// isVIPServiceIP reports whether ip is an IP address that's
// assigned to a VIP service.
func (ns *Impl) isVIPServiceIP(ip netip.Addr) bool {
return ns.atomicIsVIPServiceIPFunc.Load()(ip)
}
func (ns *Impl) peerAPIPortAtomic(ip netip.Addr) *atomic.Uint32 {
if ip.Is4() {
return &ns.peerapiPort4Atomic
@@ -968,6 +985,7 @@ func (ns *Impl) shouldProcessInbound(p *packet.Parsed, t *tstun.Wrapper) bool {
// Handle incoming peerapi connections in netstack.
dstIP := p.Dst.Addr()
isLocal := ns.isLocalIP(dstIP)
isService := ns.isVIPServiceIP(dstIP)
// Handle TCP connection to the Tailscale IP(s) in some cases:
if ns.lb != nil && p.IPProto == ipproto.TCP && isLocal {
@@ -990,6 +1008,13 @@ func (ns *Impl) shouldProcessInbound(p *packet.Parsed, t *tstun.Wrapper) bool {
return true
}
}
if ns.lb != nil && p.IPProto == ipproto.TCP && isService {
// An assumption holds for this to work: when tun mode is on for a service,
// its tcp and web are not set. This is enforced in b.setServeConfigLocked.
if ns.lb.ShouldInterceptVIPServiceTCPPort(p.Dst) {
return true
}
}
if p.IPVersion == 6 && !isLocal && viaRange.Contains(dstIP) {
return ns.lb != nil && ns.lb.ShouldHandleViaIP(dstIP)
}