Compare commits

...

51 Commits

Author SHA1 Message Date
Jonathan Nobels
5be738b118 ipn/ipnlocal: empty allowed exit nodes syspolicy should be treated as allow all
Updates tailscale/corp#19681

If the syspolicy returns an empty list of allowed exit nodes,
this should be treated as "allow all" rather than "allow none"

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2024-06-03 10:56:45 -04:00
Anton Tolchanov
01847e0123 ipn/ipnlocal: discard node keys that have been rotated out
A non-signing node can be allowed to re-sign its new node keys following
key renewal/rotation (e.g. via `tailscale up --force-reauth`). To be
able to do this, node's TLK is written into WrappingPubkey field of the
initial SigDirect signature, signed by a signing node.

The intended use of this field implies that, for each WrappingPubkey, we
typically expect to have at most one active node with a signature
tracing back to that key. Multiple valid signatures referring to the
same WrappingPubkey can occur if a client's state has been cloned, but
it's something we explicitly discourage and don't support:
https://tailscale.com/s/clone

This change propagates rotation details (wrapping public key, a list
of previous node keys that have been rotated out) to netmap processing,
and adds tracking of obsolete node keys that, when found, will get
filtered out.

Updates tailscale/corp#19764

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-06-03 10:56:09 +01:00
Maisem Ali
42cfbf427c tsnet,wgengine/netstack: add ListenPacket and tests
This adds a new ListenPacket function on tsnet.Server
which acts mostly like `net.ListenPacket`.

Unlike `Server.Listen`, this requires listening on a
specific IP and does not automatically listen on both
V4 and V6 addresses of the Server when the IP is unspecified.

To test this, it also adds UDP support to tsdial.Dialer.UserDial
and plumbs it through the localapi. Then an associated test
to make sure the UDP functionality works from both sides.

Updates #12182

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-06-02 14:14:24 -07:00
Andrew Lytvynov
bcb55fdeb6 clientupdate: mention when Alpine system upgrade is needed (#12306)
Alpine APK repos are versioned, and contain different package sets.
Older APK releases and repos don't have the latest tailscale package.
When we report "no update available", check whether pkgs.tailscale.com
has a newer tarball release. If it does, it's possible that the system
is on an older Alpine release. Print additional messages to suggest the
user to upgrade their OS.

Fixes #11309

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-05-31 15:34:43 -07:00
Irbe Krumina
c2a4719e9e cmd/tailscale/cli: allow 'tailscale up' to succeed if --stateful-filtering is not explicitly set on linux (#12312)
This fixes an issue where, on containerized environments an upgrade
1.66.3 -> 1.66.4 failed with default containerboot configuration.
This was because containerboot by default runs 'tailscale up'
that requires all previously set flags to be explicitly provided
on subsequent runs and we explicitly set --stateful-filtering
to true on 1.66.3, removed that settingon 1.66.4.

Updates tailscale/tailscale#12307

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
Co-authored-by: Andrew Lytvynov <awly@tailscale.com>
2024-05-31 22:42:32 +01:00
Andrew Dunham
36d0ac6f8e tailcfg: use strings.CutPrefix for CheckTag; add test
Updates #cleanup

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I42eddc7547a6dd50c4d5b2a9fc88a19aac9767aa
2024-05-31 17:10:55 -04:00
ChandonPierre
0a5bd63d32 ipn/store/kubestore, cmd/containerboot: allow overriding client api server URL via ENV (#12115)
Updates tailscale/tailscale#11397

Signed-off-by: Chandon Pierre <cpierre@coreweave.com>
2024-05-31 19:39:38 +01:00
Irbe Krumina
1ec0273473 docs/k8s: fix subnet router manifests (#12305)
In https://github.com/tailscale/tailscale/pull/11363
I changed the subnet router manifest to run in tun
mode (for performance reasons), but did not
change the security context to give it net_admin,
which is required to for the tailscale socket.

Updates tailscale/tailscale#12083

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-05-31 19:15:02 +01:00
Brad Fitzpatrick
f227083539 derp: add some guardrails for derpReason metrics getting out of sync
The derp metrics got out of sync in 74eb99aed1 (2023-03).

They were fixed in 0380cbc90d (2024-05).

This adds some further guardrails (atop the previous fix) to make sure
they don't get out of sync again.

Updates #12288

Change-Id: I809061a81f8ff92f45054d0253bc13871fc71634
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-05-31 10:06:42 -07:00
Marwan Sulaiman
7e357e1636 tsweb: rename AccessLogRecord's When to Time
This change makes our access log record more consistent with the
new log/tslog package formatting of "time". Note that we can
change slog itself to call "time" "when" but we're chosing
to make this breaking change to be consistent with the std lib's
defaults.

Updates tailscale/corp#17071

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2024-05-31 12:33:35 -04:00
Spike Curtis
0380cbc90d derp: fix dropReason metrics labels (#12288)
Updates #2745
Updates #7552

Signed-off-by: Spike Curtis <spike@coder.com>
2024-05-31 07:55:04 -07:00
Anton Tolchanov
32120932a5 cmd/tailscale/cli: print node signature in tailscale lock status
- Add current node signature to `ipnstate.NetworkLockStatus`;
- Print current node signature in a human-friendly format as part
  of `tailscale lock status`.

Examples:

```
$ tailscale lock status
Tailnet lock is ENABLED.

This node is accessible under tailnet lock. Node signature:
SigKind: direct
Pubkey: [OTB3a]
KeyID: tlpub:44a0e23cd53a4b8acc02f6732813d8f5ba8b35d02d48bf94c9f1724ebe31c943
WrappingPubkey: tlpub:44a0e23cd53a4b8acc02f6732813d8f5ba8b35d02d48bf94c9f1724ebe31c943

This node's tailnet-lock key: tlpub:44a0e23cd53a4b8acc02f6732813d8f5ba8b35d02d48bf94c9f1724ebe31c943

Trusted signing keys:
	tlpub:44a0e23cd53a4b8acc02f6732813d8f5ba8b35d02d48bf94c9f1724ebe31c943	1	(self)
	tlpub:6fa21d242a202b290de85926ba3893a6861888679a73bc3a43f49539d67c9764	1	(pre-auth key kq3NzejWoS11KTM59)
```

For a node created via a signed auth key:

```
This node is accessible under tailnet lock. Node signature:
SigKind: rotation
Pubkey: [e3nAO]
Nested:
  SigKind: credential
  KeyID: tlpub:6fa21d242a202b290de85926ba3893a6861888679a73bc3a43f49539d67c9764
  WrappingPubkey: tlpub:3623b0412cab0029cb1918806435709b5947ae03554050f20caf66629f21220a
```

For a node that rotated its key a few times:

```
This node is accessible under tailnet lock. Node signature:
SigKind: rotation
Pubkey: [DOzL4]
Nested:
  SigKind: rotation
  Pubkey: [S/9yU]
  Nested:
    SigKind: rotation
    Pubkey: [9E9v4]
    Nested:
      SigKind: direct
      Pubkey: [3QHTJ]
      KeyID: tlpub:44a0e23cd53a4b8acc02f6732813d8f5ba8b35d02d48bf94c9f1724ebe31c943
      WrappingPubkey: tlpub:2faa280025d3aba0884615f710d8c50590b052c01a004c2b4c2c9434702ae9d0
```

Updates tailscale/corp#19764

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-05-31 10:11:25 +01:00
Andrew Lytvynov
776a05223b ipn/ipnlocal: support c2n updates with old systemd versions (#12296)
The `--wait` flag for `systemd-run` was added in systemd 232. While it
is quite old, it doesn't hurt to special-case them and skip the `--wait`
flag. The consequence is that we lose the update command output in logs,
but at least auto-updates will work.

Fixes #12136

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-05-30 16:55:02 -07:00
Brad Fitzpatrick
1ea100e2e5 cmd/tailscaled, ipn/conffile: support ec2 user-data config file
Updates #1412
Updates #1866

Change-Id: I4d08fb233b80c2078b3b28ffc18559baabb4a081
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-05-30 09:49:18 -07:00
Brad Fitzpatrick
2d2b62c400 wgengine/router: probe generally-unused "ip" command style lazily
This busybox fwmaskWorks check was added before we moved away from
using the "ip" command to using netlink directly.

So it's now just wasted work (and log spam on Gokrazy) to check the
"ip" command capabilities if we're never going to use it.

Do it lazily instead.

Updates #12277

Change-Id: I8ab9acf64f9c0d8240ce068cb9ec8c0f6b1ecee7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-05-29 21:02:45 -07:00
Brad Fitzpatrick
909a292a8d util/linuxfw: don't try cleaning iptables on gokrazy
It just generates log spam.

Updates #12277

Change-Id: I5f65c0859e86de0a5349f9d26c9805e7c26b9371
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-05-29 21:02:45 -07:00
Walter Poupore
0acb61fbf8 serve.go, tsnet.go: Fix "in in" typo (#12279)
Fixes #cleanup

Signed-off-by: Walter Poupore <walterp@tailscale.com>
2024-05-29 14:11:00 -07:00
Andrea Gottardo
dd77111462 xcode/iOS: set MatchDomains when no route requires a custom DNS resolver (#10576)
Updates https://github.com/tailscale/corp/issues/15802.

On iOS exclusively, this PR adds logic to use a split DNS configuration in more cases, with the goal of improving battery life. Acting as the global DNS resolver on iOS should be avoided, as it leads to frequent wakes of IPNExtension.

We try to determine if we can have Tailscale only handle DNS queries for resources inside the tailnet, that is, all routes in the DNS configuration do not require a custom resolver (this is the case for app connectors, for instance).

If so, we set all Routes as MatchDomains. This enables a split DNS configuration which will help preserve battery life. Effectively, for the average Tailscale user who only relies on MagicDNS to resolve *.ts.net domains, this means that Tailscale DNS will only be used for those domains.

This PR doesn't affect users with Override Local DNS enabled. For these users, there should be no difference and Tailscale will continue acting as a global DNS resolver.

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
2024-05-29 12:11:02 -07:00
Percy Wegmann
08a9551a73 ssh/tailssh: fall back to using su when no TTY available on Linux
This allows pam authentication to run for ssh sessions, triggering
automation like pam_mkhomedir.

Updates #11854

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-05-29 13:15:17 -05:00
Claire Wang
f1d10c12ac ipn/ipnlocal: allowed suggested exit nodes policy (#12240)
Updates tailscale/corp#19681

Signed-off-by: Claire Wang <claire@tailscale.com>
2024-05-27 16:22:36 -04:00
signed-long
5ad0dad15e go generate directives reorder for 'make kube-generate-all' (#12210)
Fixes #11980

Signed-off-by: Michael Long <michaelongdev@gmail.com>
2024-05-27 09:09:34 +01:00
Irbe Krumina
d0d33f257f cmd/k8s-operator: add a note pointing at ProxyClass (#12246)
Updates tailscale/tailscale#12242

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-05-26 15:14:26 +01:00
Andrew Dunham
8e4a29433f util/pool: add package for storing and using a pool of items
This can be used to implement a persistent pool (i.e. one that isn't
cleared like sync.Pool is) of items–e.g. database connections.

Some benchmarks vs. a naive implementation that uses a single map
iteration show a pretty meaningful improvement:

    $ benchstat -col /impl ./bench.txt
    goos: darwin
    goarch: arm64
    pkg: tailscale.com/util/pool
                       │    Pool     │                   map                    │
                       │   sec/op    │     sec/op      vs base                  │
    Pool_AddDelete-10    10.56n ± 2%     15.11n ±  1%    +42.97% (p=0.000 n=10)
    Pool_TakeRandom-10   56.75n ± 4%   1899.50n ± 20%  +3246.84% (p=0.000 n=10)
    geomean              24.49n          169.4n         +591.74%

Updates tailscale/corp#19900

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ie509cb65573c4726cfc3da9a97093e61c216ca18
2024-05-24 14:11:19 -04:00
James Tucker
87ee559b6f net/netcheck: apply some polish suggested from #12161
Apply some post-submit code review suggestions.

Updates #12161
Updates tailscale/corp#19106

Signed-off-by: James Tucker <james@tailscale.com>
2024-05-24 10:43:07 -07:00
Maisem Ali
9a64c06a20 all: do not depend on the testing package
Discovered while looking for something else.

Updates tailscale/corp#18935

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-05-24 05:23:36 -07:00
Jordan Whited
4214e5f71b logtail/backoff: update Backoff.BackOff docs (#12229)
Update #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-05-23 09:53:05 -07:00
James Tucker
538c2e8f7c tool/gocross: add debug data to CGO builds
We don't build a lot of tools with CGO, but we do build some, and it's
extremely valuable for production services in particular to have symbols
included - for perf and so on.

I tested various other builds that could be affected negatively, in
particular macOS/iOS, but those use split-dwarf already as part of their
build path, and Android which does not currently use gocross.

One binary which is normally 120mb only grew to 123mb, so the trade-off
is definitely worthwhile in context.

Updates tailscale/corp#20296

Signed-off-by: James Tucker <james@tailscale.com>
2024-05-22 20:47:28 -07:00
Brad Fitzpatrick
3c9be07214 cmd/derper: support TXT-mediated unpublished bootstrap DNS rollouts
Updates tailscale/coral#127

Change-Id: I2712c50630d0d1272c30305fa5a1899a19ffacef
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-05-22 12:03:38 -07:00
Irbe Krumina
72f0f53ed0 cmd/k8s-operator: fix typo (#12217)
Fixes#cleanup

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-05-22 14:59:52 +01:00
James Tucker
9351eec3e1 net/netcheck: remove hairpin probes
Palo Alto reported interpreting hairpin probes as LAND attacks, and the
firewalls may be responding to this by shutting down otherwise in use NAT sessions
prematurely. We don't currently make use of the outcome of the hairpin
probes, and they contribute to other user confusion with e.g. the
AirPort Extreme hairpin session workaround. We decided in response to
remove the whole probe feature as a result.

Updates #188
Updates tailscale/corp#19106
Updates tailscale/corp#19116

Signed-off-by: James Tucker <james@tailscale.com>
2024-05-21 12:55:27 -07:00
Andrew Lytvynov
c9179bc261 various: disable stateful filtering by default (#12197)
After some analysis, stateful filtering is only necessary in tailnets
that use `autogroup:danger-all` in `src` in ACLs. And in those cases
users explicitly specify that hosts outside of the tailnet should be
able to reach their nodes. To fix local DNS breakage in containers, we
disable stateful filtering by default.

Updates #12108

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-05-20 11:44:29 -07:00
License Updater
6db1219185 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-05-20 08:40:52 -07:00
Charlotte Brandhorst-Satzkorn
4f4f317174 api.md: direct TOC links to new publicapi docs location
This change updates the existing api.md TOC links to point at the new
publicapi folder/files. It also removes the body of the docs from the
file, to avoid the docs becoming out of sync.

This change also renames overview.md to readme.md.

Updates tailscale/corp#19526

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2024-05-20 11:15:44 -04:00
Brad Fitzpatrick
964282d34f ipn,wgengine: remove vestigial Prefs.AllowSingleHosts
It was requested by the first customer 4-5 years ago and only used
for a brief moment of time. We later added netmap visibility trimming
which removes the need for this.

It's been hidden by the CLI for quite some time and never documented
anywhere else.

This keeps the CLI flag, though, out of caution. It just returns an
error if it's set to anything but true (its default).

Fixes #12058

Change-Id: I7514ba572e7b82519b04ed603ff9f3bdbaecfda7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-05-17 20:50:19 -07:00
Brad Fitzpatrick
1384c24e41 control/controlclient: delete unused Client.Login Oauth2Token field
Updates #12172 (then need to update other repos)

Change-Id: I439f65e0119b09e00da2ef5c7a4f002f93558578
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-05-17 19:51:18 -07:00
Andrew Dunham
47b3476eb7 util/lru: add Clear method
Updates tailscale/corp#20109

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I751a669251a70f0134dd1540c19b274a97608a93
2024-05-17 20:01:40 -04:00
Charlotte Brandhorst-Satzkorn
c56e0c4934 publicapi: include device and user invites API documentation (#12168)
This change includes the device and user invites API docs in the
new publicapi documentation structure.

Updates tailscale/corp#19526

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2024-05-17 15:55:26 -07:00
Jordan Whited
adb7a86559 cmd/stunc: support ipv6 address targets (#12166)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-05-17 12:02:57 -07:00
James Tucker
8d1249550a net/netcheck,wgengine/magicsock: add potential workaround for Palo Alto DIPP misbehavior
Palo Alto firewalls have a typically hard NAT, but also have a mode
called Persistent DIPP that is supposed to provide consistent port
mapping suitable for STUN resolution of public ports. Persistent DIPP
works initially on most Palo Alto firewalls, but some models/software
versions have a bug which this works around.

The bug symptom presents as follows:

- STUN sessions resolve a consistent public IP:port to start with
- Much later netchecks report the same IP:Port for a subset of
  sessions, most often the users active DERP, and/or the port related
  to sustained traffic.
- The broader set of DERPs in a full netcheck will now consistently
  observe a new IP:Port.
- After this point of observation, new inbound connections will only
  succeed to the new IP:Port observed, and existing/old sessions will
  only work to the old binding.

In this patch we now advertise the lowest latency global endpoint
discovered as we always have, but in addition any global endpoints that
are observed more than once in a single netcheck report. This should
provide viable endpoints for potential connection establishment across
a NAT with this behavior.

Updates tailscale/corp#19106

Signed-off-by: James Tucker <james@tailscale.com>
2024-05-17 10:26:59 -07:00
Charlotte Brandhorst-Satzkorn
6831a29f8b publicapi: create new home for API docs and split into catagory files (#12116)
This change creates a new folder called publicapi that will become the
future home to the Tailscale public API docs.

This change also splits the existing API docs (still located in api.md)
into separate files, for easier reading and contribution.

Updates tailscale/corp#19526

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2024-05-16 16:19:31 -07:00
Andrea Gottardo
e5f67f90a2 xcode: allow ICMP ping relay on macOS + iOS platforms (#12048)
Fixes tailscale/tailscale#10393
Fixes tailscale/corp#15412
Fixes tailscale/corp#19808

On Apple platforms, exit nodes and subnet routers have been unable to relay pings from Tailscale devices to non-Tailscale devices due to sandbox restrictions imposed on our network extensions by Apple. The sandbox prevented the code in netstack.go from spawning the `ping` process which we were using.

Replace that exec call with logic to send an ICMP echo request directly, which appears to work in userspace, and not trigger a sandbox violation in the syslog.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-05-16 11:57:57 -07:00
Percy Wegmann
59848fe14b drive: rewrite LOCK paths
Fixes #12097

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-05-16 13:42:45 -05:00
James Tucker
87f00d76c4 tool/gocross: treat empty GOOS/GOARCH as native GOOS/GOARCH
Tracking down the side effect can otherwise be a pain, for example on
Darwin an empty GOOS resulted in CGO being implicitly disabled. The user
intended for `export GOOS=` to act like unset, and while this is a
misunderstanding, the main toolchain would treat it this way.

Fixes tailscale/corp#20059

Signed-off-by: James Tucker <james@tailscale.com>
2024-05-16 11:23:31 -07:00
Irbe Krumina
76c30e014d cmd/containerboot: warn when an ingress proxy with an IPv4 tailnet address is being created for an IPv6 backend(s) (#12159)
Updates tailscale/tailscale#12156

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-05-16 18:11:30 +01:00
Maisem Ali
8feb4ff5d2 version: add GitCommitTime to Meta
Updates tailscale/corp#1297

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-05-16 10:53:50 -04:00
Maisem Ali
359ef61263 Revert "version: add Info func to expose EmbeddedInfo"
This reverts commit e3dec086e6.

Going to reuse Meta instead as that is already exported.

Updates tailscale/corp#1297

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-05-16 10:53:50 -04:00
Sonia Appasamy
89947606b2 api.md: document device invite apis
Updates tailscale/corp#18153

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2024-05-15 13:53:47 -04:00
Sonia Appasamy
b094e8c925 api.md: document user invite apis
Updates tailscale/corp#18153

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2024-05-15 13:12:17 -04:00
Maisem Ali
e3dec086e6 version: add Info func to expose EmbeddedInfo
To be used to in a different repo.

Updates tailscale/corp#1297

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-05-15 13:09:34 -04:00
Kevin Liang
7f83f9fc83 Net/DNS/Publicdns: update the IPv6 range that we use to recreate route endpoint for control D
In this commit I updated the Ipv6 range we use to generate Control D DOH ip, we were using the NextDNSRanges to generate Control D DOH ip, updated to use the correct range.

Updates: #7946
Signed-off-by: Kevin Liang <kevinliang@tailscale.com>
2024-05-15 12:21:58 -04:00
Brad Fitzpatrick
6877d44965 prober: plumb a now-required netmon to derphttp
Updates #11896

Change-Id: Ie2f9cd024d85b51087d297aa36c14a9b8a2b8129
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-05-15 10:35:26 -04:00
116 changed files with 6444 additions and 3522 deletions

View File

@@ -115,10 +115,7 @@ sshintegrationtest: ## Run the SSH integration tests in various Docker container
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
echo "Testing on fedora:38" && docker build --build-arg="BASE=dokken/fedora-38" -t ssh-fedora-38 ssh/tailssh/testcontainers && \
echo "Testing on fedora:39" && docker build --build-arg="BASE=dokken/fedora-39" -t ssh-fedora-39 ssh/tailssh/testcontainers && \
echo "Testing on fedora:40" && docker build --build-arg="BASE=dokken/fedora-40" -t ssh-fedora-40 ssh/tailssh/testcontainers
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"

2163
api.md

File diff suppressed because it is too large Load Diff

View File

@@ -778,6 +778,17 @@ func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
//
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
return lc.UserDial(ctx, "tcp", host, port)
}
// UserDial connects to the host's port via Tailscale for the given network.
//
// The host may be a base DNS name (resolved from the netmap inside tailscaled),
// a FQDN, or an IP address.
//
// The ctx is only used for the duration of the call, not the lifetime of the
// net.Conn.
func (lc *LocalClient) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
@@ -790,10 +801,11 @@ func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (n
return nil, err
}
req.Header = http.Header{
"Upgrade": []string{"ts-dial"},
"Connection": []string{"upgrade"},
"Dial-Host": []string{host},
"Dial-Port": []string{fmt.Sprint(port)},
"Upgrade": []string{"ts-dial"},
"Connection": []string{"upgrade"},
"Dial-Host": []string{host},
"Dial-Port": []string{fmt.Sprint(port)},
"Dial-Network": []string{network},
}
res, err := lc.DoLocalRequest(req)
if err != nil {

View File

@@ -35,6 +35,7 @@ func TestDeps(t *testing.T) {
BadDeps: map[string]string{
// Make sure we don't again accidentally bring in a dependency on
// drive or its transitive dependencies
"testing": "do not use testing package in production code",
"tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
},

View File

@@ -653,6 +653,9 @@ func (up *Updater) updateAlpineLike() (err error) {
return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
}
if !up.confirm(ver) {
if err := checkOutdatedAlpineRepo(up.Logf, ver, up.Track); err != nil {
up.Logf("failed to check whether Alpine release is outdated: %v", err)
}
return nil
}
@@ -690,6 +693,37 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
return "", errors.New("tailscale version not found in output")
}
var apkRepoVersionRE = regexp.MustCompile(`v[0-9]+\.[0-9]+`)
func checkOutdatedAlpineRepo(logf logger.Logf, apkVer, track string) error {
latest, err := LatestTailscaleVersion(track)
if err != nil {
return err
}
if latest == apkVer {
// Actually on latest release.
return nil
}
f, err := os.Open("/etc/apk/repositories")
if err != nil {
return err
}
defer f.Close()
// Read the first repo line. Typically, there are multiple repos that all
// contain the same version in the path, like:
// https://dl-cdn.alpinelinux.org/alpine/v3.20/main
// https://dl-cdn.alpinelinux.org/alpine/v3.20/community
s := bufio.NewScanner(f)
if !s.Scan() {
return s.Err()
}
alpineVer := apkRepoVersionRE.FindString(s.Text())
if alpineVer != "" {
logf("The latest Tailscale release for Linux is %q, but your apk repository only provides %q.\nYour Alpine version is %q, you may need to upgrade the system to get the latest Tailscale version: https://wiki.alpinelinux.org/wiki/Upgrading_Alpine", latest, apkVer, alpineVer)
}
return nil
}
func (up *Updater) updateMacSys() error {
return errors.New("NOTREACHED: On MacSys builds, `tailscale update` is handled in Swift to launch the GUI updater")
}

View File

@@ -138,9 +138,9 @@ func initKubeClient(root string) {
if err != nil {
log.Fatalf("Error creating kube client: %v", err)
}
if root != "/" {
// If we are running in a test, we need to set the URL to the
// httptest server.
if (root != "/") || os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
// Derive the API server address from the environment variables
// Used to set http server in tests, or optionally enabled by flag
kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
}
}

View File

@@ -961,16 +961,23 @@ func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []ne
return err
}
var local netip.Addr
proxyHasIPv4Address := false
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() {
proxyHasIPv4Address = true
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if proxyHasIPv4Address && dst.Is6() {
log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156")
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}

View File

@@ -5,35 +5,45 @@ package main
import (
"context"
"encoding/binary"
"encoding/json"
"expvar"
"log"
"math/rand/v2"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync/atomic"
"time"
"tailscale.com/syncs"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
)
const refreshTimeout = time.Minute
type dnsEntryMap map[string][]net.IP
type dnsEntryMap struct {
IPs map[string][]net.IP
Percent map[string]float64 // "foo.com" => 0.5 for 50%
}
var (
dnsCache syncs.AtomicValue[dnsEntryMap]
dnsCache atomic.Pointer[dnsEntryMap]
dnsCacheBytes syncs.AtomicValue[[]byte] // of JSON
unpublishedDNSCache syncs.AtomicValue[dnsEntryMap]
unpublishedDNSCache atomic.Pointer[dnsEntryMap]
bootstrapLookupMap syncs.Map[string, bool]
)
var (
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
unpublishedDNSPercentMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_percent_misses")
)
func init() {
@@ -59,15 +69,13 @@ func refreshBootstrapDNS() {
}
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
defer cancel()
dnsEntries := resolveList(ctx, strings.Split(*bootstrapDNS, ","))
dnsEntries := resolveList(ctx, *bootstrapDNS)
// Randomize the order of the IPs for each name to avoid the client biasing
// to IPv6
for k := range dnsEntries {
ips := dnsEntries[k]
slicesx.Shuffle(ips)
dnsEntries[k] = ips
for _, vv := range dnsEntries.IPs {
slicesx.Shuffle(vv)
}
j, err := json.MarshalIndent(dnsEntries, "", "\t")
j, err := json.MarshalIndent(dnsEntries.IPs, "", "\t")
if err != nil {
// leave the old values in place
return
@@ -81,27 +89,50 @@ func refreshUnpublishedDNS() {
if *unpublishedDNS == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
defer cancel()
dnsEntries := resolveList(ctx, strings.Split(*unpublishedDNS, ","))
dnsEntries := resolveList(ctx, *unpublishedDNS)
unpublishedDNSCache.Store(dnsEntries)
}
func resolveList(ctx context.Context, names []string) dnsEntryMap {
dnsEntries := make(dnsEntryMap)
// resolveList takes a comma-separated list of DNS names to resolve.
//
// If an entry contains a slash, it's two DNS names: the first is the one to
// resolve and the second is that of a TXT recording containing the rollout
// percentage in range "0".."100". If the TXT record doesn't exist or is
// malformed, the percentage is 0. If the TXT record is not provided (there's no
// slash), then the percentage is 100.
func resolveList(ctx context.Context, list string) *dnsEntryMap {
ents := strings.Split(list, ",")
ret := &dnsEntryMap{}
var r net.Resolver
for _, name := range names {
for _, ent := range ents {
name, txtName, _ := strings.Cut(ent, "/")
addrs, err := r.LookupIP(ctx, "ip", name)
if err != nil {
log.Printf("bootstrap DNS lookup %q: %v", name, err)
continue
}
dnsEntries[name] = addrs
mak.Set(&ret.IPs, name, addrs)
if txtName == "" {
mak.Set(&ret.Percent, name, 1.0)
continue
}
vals, err := r.LookupTXT(ctx, txtName)
if err != nil {
log.Printf("bootstrap DNS lookup %q: %v", txtName, err)
continue
}
for _, v := range vals {
if v, err := strconv.Atoi(v); err == nil && v >= 0 && v <= 100 {
mak.Set(&ret.Percent, name, float64(v)/100)
}
}
}
return dnsEntries
return ret
}
func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
@@ -115,22 +146,36 @@ func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
// Try answering a query from our hidden map first
if q := r.URL.Query().Get("q"); q != "" {
bootstrapLookupMap.Store(q, true)
if ips, ok := unpublishedDNSCache.Load()[q]; ok && len(ips) > 0 {
if bootstrapLookupMap.Len() > 500 { // defensive
bootstrapLookupMap.Clear()
}
if m := unpublishedDNSCache.Load(); m != nil && len(m.IPs[q]) > 0 {
unpublishedDNSHits.Add(1)
// Only return the specific query, not everything.
m := dnsEntryMap{q: ips}
j, err := json.MarshalIndent(m, "", "\t")
if err == nil {
w.Write(j)
return
percent := m.Percent[q]
if remoteAddrMatchesPercent(r.RemoteAddr, percent) {
// Only return the specific query, not everything.
m := map[string][]net.IP{q: m.IPs[q]}
j, err := json.MarshalIndent(m, "", "\t")
if err == nil {
w.Write(j)
return
}
} else {
unpublishedDNSPercentMisses.Add(1)
}
}
// If we have a "q" query for a name in the published cache
// list, then track whether that's a hit/miss.
if m, ok := dnsCache.Load()[q]; ok {
if len(m) > 0 {
m := dnsCache.Load()
var inPub bool
var ips []net.IP
if m != nil {
ips, inPub = m.IPs[q]
}
if inPub {
if len(ips) > 0 {
publishedDNSHits.Add(1)
} else {
publishedDNSMisses.Add(1)
@@ -146,3 +191,29 @@ func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
j := dnsCacheBytes.Load()
w.Write(j)
}
// percent is [0.0, 1.0].
func remoteAddrMatchesPercent(remoteAddr string, percent float64) bool {
if percent == 0 {
return false
}
if percent == 1 {
return true
}
reqIPStr, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return false
}
reqIP, err := netip.ParseAddr(reqIPStr)
if err != nil {
return false
}
if reqIP.IsLoopback() {
// For local testing.
return rand.Float64() < 0.5
}
reqIP16 := reqIP.As16()
rndSrc := rand.NewPCG(binary.LittleEndian.Uint64(reqIP16[:8]), binary.LittleEndian.Uint64(reqIP16[8:]))
rnd := rand.New(rndSrc)
return percent > rnd.Float64()
}

View File

@@ -4,10 +4,13 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"reflect"
"testing"
@@ -38,7 +41,7 @@ func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p),
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
func getBootstrapDNS(t *testing.T, q string) dnsEntryMap {
func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
t.Helper()
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
w := httptest.NewRecorder()
@@ -48,11 +51,12 @@ func getBootstrapDNS(t *testing.T, q string) dnsEntryMap {
if res.StatusCode != 200 {
t.Fatalf("got status=%d; want %d", res.StatusCode, 200)
}
var ips dnsEntryMap
if err := json.NewDecoder(res.Body).Decode(&ips); err != nil {
t.Fatalf("error decoding response body: %v", err)
var m map[string][]net.IP
var buf bytes.Buffer
if err := json.NewDecoder(io.TeeReader(res.Body, &buf)).Decode(&m); err != nil {
t.Fatalf("error decoding response body %q: %v", buf.Bytes(), err)
}
return ips
return m
}
func TestUnpublishedDNS(t *testing.T) {
@@ -107,15 +111,21 @@ func resetMetrics() {
// Verify that we don't count an empty list in the unpublishedDNSCache as a
// cache hit in our metrics.
func TestUnpublishedDNSEmptyList(t *testing.T) {
pub := dnsEntryMap{
"tailscale.com": {net.IPv4(10, 10, 10, 10)},
pub := &dnsEntryMap{
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
}
dnsCache.Store(pub)
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
unpublishedDNSCache.Store(dnsEntryMap{
"log.tailscale.io": {},
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
unpublishedDNSCache.Store(&dnsEntryMap{
IPs: map[string][]net.IP{
"log.tailscale.io": {},
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
},
Percent: map[string]float64{
"log.tailscale.io": 1.0,
"controlplane.tailscale.com": 1.0,
},
})
t.Run("CacheMiss", func(t *testing.T) {
@@ -125,8 +135,8 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
ips := getBootstrapDNS(t, q)
// Expected our public map to be returned on a cache miss
if !reflect.DeepEqual(ips, pub) {
t.Errorf("got ips=%+v; want %+v", ips, pub)
if !reflect.DeepEqual(ips, pub.IPs) {
t.Errorf("got ips=%+v; want %+v", ips, pub.IPs)
}
if v := unpublishedDNSHits.Value(); v != 0 {
t.Errorf("got hits=%d; want 0", v)
@@ -141,7 +151,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
t.Run("CacheHit", func(t *testing.T) {
resetMetrics()
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
want := dnsEntryMap{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
if !reflect.DeepEqual(ips, want) {
t.Errorf("got ips=%+v; want %+v", ips, want)
}
@@ -166,3 +176,54 @@ func TestLookupMetric(t *testing.T) {
t.Errorf("bootstrapLookupMap.Len() want=5, got %v", bootstrapLookupMap.Len())
}
}
func TestRemoteAddrMatchesPercent(t *testing.T) {
tests := []struct {
remoteAddr string
percent float64
want bool
}{
// 0% and 100%.
{"10.0.0.1:1234", 0.0, false},
{"10.0.0.1:1234", 1.0, true},
// Invalid IP.
{"", 1.0, true},
{"", 0.0, false},
{"", 0.5, false},
// Small manual sample at 50%. The func uses a deterministic PRNG seed.
{"1.2.3.4:567", 0.5, true},
{"1.2.3.5:567", 0.5, true},
{"1.2.3.6:567", 0.5, false},
{"1.2.3.7:567", 0.5, true},
{"1.2.3.8:567", 0.5, false},
{"1.2.3.9:567", 0.5, true},
{"1.2.3.10:567", 0.5, true},
}
for _, tt := range tests {
got := remoteAddrMatchesPercent(tt.remoteAddr, tt.percent)
if got != tt.want {
t.Errorf("remoteAddrMatchesPercent(%q, %v) = %v; want %v", tt.remoteAddr, tt.percent, got, tt.want)
}
}
var match, all int
const wantPercent = 0.5
for a := range 256 {
for b := range 256 {
all++
if remoteAddrMatchesPercent(
netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 2, byte(a), byte(b)}), 12345).String(),
wantPercent) {
match++
}
}
}
gotPercent := float64(match) / float64(all)
const tolerance = 0.005
t.Logf("got percent %v (goal %v)", gotPercent, wantPercent)
if gotPercent < wantPercent-tolerance || gotPercent > wantPercent+tolerance {
t.Errorf("got %v; want %v ± %v", gotPercent, wantPercent, tolerance)
}
}

View File

@@ -235,7 +235,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
encoding/pem from crypto/tls+
errors from bufio+
expvar from github.com/prometheus/client_golang/prometheus+
flag from tailscale.com/cmd/derper+
flag from tailscale.com/cmd/derper
fmt from compress/flate+
go/token from google.golang.org/protobuf/internal/strs
hash from crypto+
@@ -253,7 +253,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
math/big from crypto/dsa+
math/bits from compress/flate+
math/rand from github.com/mdlayher/netlink+
math/rand/v2 from tailscale.com/util/fastuuid
math/rand/v2 from tailscale.com/util/fastuuid+
mime from github.com/prometheus/common/expfmt+
mime/multipart from net/http
mime/quotedprintable from mime/multipart
@@ -277,7 +277,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
runtime/debug from github.com/prometheus/client_golang/prometheus+
runtime/metrics from github.com/prometheus/client_golang/prometheus+
runtime/pprof from net/http/pprof
runtime/trace from net/http/pprof+
runtime/trace from net/http/pprof
slices from tailscale.com/ipn/ipnstate+
sort from compress/flate+
strconv from compress/flate+
@@ -285,7 +285,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
sync from compress/flate+
sync/atomic from context+
syscall from crypto/rand+
testing from tailscale.com/util/syspolicy
text/tabwriter from runtime/pprof
time from compress/gzip+
unicode from bytes+

View File

@@ -55,7 +55,7 @@ var (
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list")
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list. If an entry contains a slash, the second part names a DNS record to poll for its TXT record with a `0` to `100` value for rollout percentage.")
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
verifyClientURL = flag.String("verify-client-url", "", "if non-empty, an admission controller URL for permitting client connections; see tailcfg.DERPAdmitClientRequest")
verifyFailOpen = flag.Bool("verify-client-url-fail-open", true, "whether we fail open if --verify-client-url is unreachable")

View File

@@ -99,6 +99,7 @@ func TestNoContent(t *testing.T) {
func TestDeps(t *testing.T) {
deptest.DepChecker{
BadDeps: map[string]string{
"testing": "do not use testing package in production code",
"gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",

View File

@@ -51,6 +51,10 @@ operatorConfig:
# proxies created by the operator.
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-ingress
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-egress
# Note that this section contains only a few global configuration options and
# will not be updated with more configuration options in the future.
# If you need more configuration options, take a look at ProxyClass:
# https://tailscale.com/kb/1236/kubernetes-operator#cluster-resource-customization-using-proxyclass-custom-resource
proxyConfig:
image:
repo: tailscale/tailscale

View File

@@ -45,12 +45,12 @@ import (
"tailscale.com/version"
)
// Generate static manifests for deploying Tailscale operator on Kubernetes from the operator's Helm chart.
//go:generate go run tailscale.com/cmd/k8s-operator/generate staticmanifests
// Generate Connector and ProxyClass CustomResourceDefinition yamls from their Go types.
//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen crd schemapatch:manifests=./deploy/crds output:dir=./deploy/crds paths=../../k8s-operator/apis/...
// Generate static manifests for deploying Tailscale operator on Kubernetes from the operator's Helm chart.
//go:generate go run tailscale.com/cmd/k8s-operator/generate staticmanifests
// Generate CRD docs from the yamls
//go:generate go run fybrik.io/crdoc --resources=./deploy/crds --output=../../k8s-operator/api.md

View File

@@ -161,7 +161,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
}
if violations := validateService(svc); len(violations) > 0 {
msg := fmt.Sprintf("unable to provision proxy resources: invalid Service: %s", strings.Join(violations, ", "))
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVCICE", msg)
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVICE", msg)
a.logger.Error(msg)
return nil
}

View File

@@ -20,7 +20,7 @@ func main() {
}
host := os.Args[1]
uaddr, err := net.ResolveUDPAddr("udp", host+":3478")
uaddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, "3478"))
if err != nil {
log.Fatal(err)
}

View File

@@ -24,6 +24,7 @@ import (
"tailscale.com/tka"
"tailscale.com/tstest"
"tailscale.com/types/logger"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
"tailscale.com/version/distro"
@@ -176,9 +177,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "bare_up_means_up",
flags: []string{},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
Hostname: "foo",
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
Hostname: "foo",
NoStatefulFiltering: opt.NewBool(true),
},
want: "",
},
@@ -186,12 +188,12 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "losing_hostname",
flags: []string{"--accept-dns"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
Hostname: "foo",
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AllowSingleHosts: true,
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
Hostname: "foo",
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --accept-dns --hostname=foo",
},
@@ -199,11 +201,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "hostname_changing_explicitly",
flags: []string{"--hostname=bar"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AllowSingleHosts: true,
Hostname: "foo",
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
Hostname: "foo",
NoStatefulFiltering: opt.NewBool(true),
},
want: "",
},
@@ -211,11 +213,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "hostname_changing_empty_explicitly",
flags: []string{"--hostname="},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AllowSingleHosts: true,
Hostname: "foo",
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
Hostname: "foo",
NoStatefulFiltering: opt.NewBool(true),
},
want: "",
},
@@ -231,11 +233,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "implicit_operator_change",
flags: []string{"--hostname=foo"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
OperatorUser: "alice",
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
OperatorUser: "alice",
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
curUser: "eve",
want: accidentalUpPrefix + " --hostname=foo --operator=alice",
@@ -244,11 +246,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "implicit_operator_matches_shell_user",
flags: []string{"--hostname=foo"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
OperatorUser: "alice",
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
OperatorUser: "alice",
NoStatefulFiltering: opt.NewBool(true),
},
curUser: "alice",
want: "",
@@ -257,15 +259,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "error_advertised_routes_exit_node_removed",
flags: []string{"--advertise-routes=10.0.42.0/24"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.42.0/24"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --advertise-routes=10.0.42.0/24 --advertise-exit-node",
},
@@ -273,15 +275,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "advertised_routes_exit_node_removed_explicit",
flags: []string{"--advertise-routes=10.0.42.0/24", "--advertise-exit-node=false"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.42.0/24"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
NoStatefulFiltering: opt.NewBool(true),
},
want: "",
},
@@ -289,15 +291,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "advertised_routes_includes_the_0_routes", // but no --advertise-exit-node
flags: []string{"--advertise-routes=11.1.43.0/24,0.0.0.0/0,::/0"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.42.0/24"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
NoStatefulFiltering: opt.NewBool(true),
},
want: "",
},
@@ -305,10 +307,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "advertise_exit_node", // Issue 1859
flags: []string{"--advertise-exit-node"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
want: "",
},
@@ -316,14 +318,14 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "advertise_exit_node_over_existing_routes",
flags: []string{"--advertise-exit-node"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("1.2.0.0/16"),
},
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
},
@@ -331,15 +333,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "advertise_exit_node_over_existing_routes_and_exit_node",
flags: []string{"--advertise-exit-node"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
netip.MustParsePrefix("1.2.0.0/16"),
},
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
},
@@ -347,12 +349,12 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "exit_node_clearing", // Issue 1777
flags: []string{"--exit-node="},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ExitNodeID: "fooID",
ExitNodeID: "fooID",
NoStatefulFiltering: opt.NewBool(true),
},
want: "",
},
@@ -360,59 +362,59 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "remove_all_implicit",
flags: []string{"--force-reauth"},
curPrefs: &ipn.Prefs{
WantRunning: true,
ControlURL: ipn.DefaultControlURL,
RouteAll: true,
AllowSingleHosts: false,
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
CorpDNS: false,
ShieldsUp: true,
AdvertiseTags: []string{"tag:foo", "tag:bar"},
Hostname: "myhostname",
ForceDaemon: true,
WantRunning: true,
ControlURL: ipn.DefaultControlURL,
RouteAll: true,
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
CorpDNS: false,
ShieldsUp: true,
AdvertiseTags: []string{"tag:foo", "tag:bar"},
Hostname: "myhostname",
ForceDaemon: true,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/16"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
NetfilterMode: preftype.NetfilterNoDivert,
OperatorUser: "alice",
NetfilterMode: preftype.NetfilterNoDivert,
OperatorUser: "alice",
NoStatefulFiltering: opt.NewBool(true),
},
curUser: "eve",
want: accidentalUpPrefix + " --force-reauth --accept-dns=false --accept-routes --advertise-exit-node --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --host-routes=false --hostname=myhostname --netfilter-mode=nodivert --operator=alice --shields-up",
want: accidentalUpPrefix + " --force-reauth --accept-dns=false --accept-routes --advertise-exit-node --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --hostname=myhostname --netfilter-mode=nodivert --operator=alice --shields-up",
},
{
name: "remove_all_implicit_except_hostname",
flags: []string{"--hostname=newhostname"},
curPrefs: &ipn.Prefs{
WantRunning: true,
ControlURL: ipn.DefaultControlURL,
RouteAll: true,
AllowSingleHosts: false,
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
CorpDNS: false,
ShieldsUp: true,
AdvertiseTags: []string{"tag:foo", "tag:bar"},
Hostname: "myhostname",
ForceDaemon: true,
WantRunning: true,
ControlURL: ipn.DefaultControlURL,
RouteAll: true,
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
CorpDNS: false,
ShieldsUp: true,
AdvertiseTags: []string{"tag:foo", "tag:bar"},
Hostname: "myhostname",
ForceDaemon: true,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/16"),
},
NetfilterMode: preftype.NetfilterNoDivert,
OperatorUser: "alice",
NetfilterMode: preftype.NetfilterNoDivert,
OperatorUser: "alice",
NoStatefulFiltering: opt.NewBool(true),
},
curUser: "eve",
want: accidentalUpPrefix + " --hostname=newhostname --accept-dns=false --accept-routes --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --host-routes=false --netfilter-mode=nodivert --operator=alice --shields-up",
want: accidentalUpPrefix + " --hostname=newhostname --accept-dns=false --accept-routes --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --netfilter-mode=nodivert --operator=alice --shields-up",
},
{
name: "loggedout_is_implicit",
flags: []string{"--hostname=foo"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
LoggedOut: true,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
LoggedOut: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
want: "", // not an error. LoggedOut is implicit.
},
@@ -422,10 +424,9 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "make_windows_exit_node",
flags: []string{"--advertise-exit-node"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
RouteAll: true,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
RouteAll: true,
// And assume this no-op accidental pre-1.8 value:
NoSNAT: true,
@@ -437,8 +438,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "ignore_netfilter_change_non_linux",
flags: []string{"--accept-dns"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
ControlURL: ipn.DefaultControlURL,
NetfilterMode: preftype.NetfilterNoDivert, // we never had this bug, but pretend it got set non-zero on Windows somehow
},
@@ -449,15 +449,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "operator_losing_routes_step1", // https://twitter.com/EXPbits/status/1390418145047887877
flags: []string{"--operator=expbits"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
netip.MustParsePrefix("1.2.0.0/16"),
},
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --operator=expbits --advertise-exit-node --advertise-routes=1.2.0.0/16",
},
@@ -465,15 +465,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "operator_losing_routes_step2", // https://twitter.com/EXPbits/status/1390418145047887877
flags: []string{"--operator=expbits", "--advertise-routes=1.2.0.0/16"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
netip.MustParsePrefix("1.2.0.0/16"),
},
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --advertise-routes=1.2.0.0/16 --operator=expbits --advertise-exit-node",
},
@@ -481,13 +481,13 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "errors_preserve_explicit_flags",
flags: []string{"--reset", "--force-reauth=false", "--authkey=secretrand"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AllowSingleHosts: true,
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
Hostname: "foo",
Hostname: "foo",
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --auth-key=secretrand --force-reauth=false --reset --hostname=foo",
},
@@ -495,12 +495,12 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "error_exit_node_omit_with_ip_pref",
flags: []string{"--hostname=foo"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ExitNodeIP: netip.MustParseAddr("100.64.5.4"),
ExitNodeIP: netip.MustParseAddr("100.64.5.4"),
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.4",
},
@@ -509,12 +509,12 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
flags: []string{"--hostname=foo"},
curExitNodeIP: netip.MustParseAddr("100.64.5.7"),
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ExitNodeID: "some_stable_id",
ExitNodeID: "some_stable_id",
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.7",
},
@@ -523,13 +523,13 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
flags: []string{"--hostname=foo"},
curExitNodeIP: netip.MustParseAddr("100.2.3.4"),
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ExitNodeAllowLANAccess: true,
ExitNodeID: "some_stable_id",
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --hostname=foo --exit-node-allow-lan-access --exit-node=100.2.3.4",
},
@@ -537,10 +537,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "ignore_login_server_synonym",
flags: []string{"--login-server=https://controlplane.tailscale.com"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
want: "", // not an error
},
@@ -548,10 +548,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "ignore_login_server_synonym_on_other_change",
flags: []string{"--netfilter-mode=off"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
AllowSingleHosts: true,
CorpDNS: false,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
CorpDNS: false,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
want: accidentalUpPrefix + " --netfilter-mode=off --accept-dns=false",
},
@@ -561,11 +561,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "synology_permit_omit_accept_routes",
flags: []string{"--hostname=foo"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
AllowSingleHosts: true,
RouteAll: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
RouteAll: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
goos: "linux",
distro: distro.Synology,
@@ -577,11 +577,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "not_synology_dont_permit_omit_accept_routes",
flags: []string{"--hostname=foo"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
AllowSingleHosts: true,
RouteAll: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
RouteAll: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
goos: "linux",
distro: "", // not Synology
@@ -591,11 +591,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
name: "profile_name_ignored_in_up",
flags: []string{"--hostname=foo"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
AllowSingleHosts: true,
NetfilterMode: preftype.NetfilterOn,
ProfileName: "foo",
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ProfileName: "foo",
NoStatefulFiltering: opt.NewBool(true),
},
goos: "linux",
want: "",
@@ -658,10 +658,9 @@ func TestPrefsFromUpArgs(t *testing.T) {
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
NoSNAT: false,
NoStatefulFiltering: "false",
NoStatefulFiltering: "true",
NetfilterMode: preftype.NetfilterOn,
CorpDNS: true,
AllowSingleHosts: true,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
},
@@ -675,10 +674,9 @@ func TestPrefsFromUpArgs(t *testing.T) {
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
CorpDNS: true,
AllowSingleHosts: true,
RouteAll: true,
NoSNAT: false,
NoStatefulFiltering: "false",
NoStatefulFiltering: "true",
NetfilterMode: preftype.NetfilterOn,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
@@ -689,15 +687,14 @@ func TestPrefsFromUpArgs(t *testing.T) {
name: "advertise_default_route",
args: upArgsFromOSArgs("linux", "--advertise-exit-node"),
want: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
AllowSingleHosts: true,
CorpDNS: true,
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
CorpDNS: true,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
NoStatefulFiltering: "false",
NoStatefulFiltering: "true",
NetfilterMode: preftype.NetfilterOn,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
@@ -922,6 +919,9 @@ func TestPrefFlagMapping(t *testing.T) {
continue
}
switch prefName {
case "AllowSingleHosts":
// Fake pref for downgrade compat. See #12058.
continue
case "WantRunning", "Persist", "LoggedOut":
// All explicitly handled (ignored) by checkForAccidentalSettingReverts.
continue
@@ -1029,7 +1029,6 @@ func TestUpdatePrefs(t *testing.T) {
wantJustEditMP: &ipn.MaskedPrefs{
AdvertiseRoutesSet: true,
AdvertiseTagsSet: true,
AllowSingleHostsSet: true,
AppConnectorSet: true,
ControlURLSet: true,
CorpDNSSet: true,
@@ -1062,11 +1061,11 @@ func TestUpdatePrefs(t *testing.T) {
name: "change_login_server",
flags: []string{"--login-server=https://localhost:1000"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
env: upCheckEnv{backendState: "Running"},
wantSimpleUp: true,
@@ -1077,11 +1076,11 @@ func TestUpdatePrefs(t *testing.T) {
name: "change_tags",
flags: []string{"--advertise-tags=tag:foo"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
env: upCheckEnv{backendState: "Running"},
},
@@ -1090,11 +1089,11 @@ func TestUpdatePrefs(t *testing.T) {
name: "explicit_empty_operator",
flags: []string{"--operator="},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
AllowSingleHosts: true,
NetfilterMode: preftype.NetfilterOn,
OperatorUser: "somebody",
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
OperatorUser: "somebody",
NoStatefulFiltering: opt.NewBool(true),
},
env: upCheckEnv{user: "somebody", backendState: "Running"},
wantJustEditMP: &ipn.MaskedPrefs{
@@ -1111,11 +1110,11 @@ func TestUpdatePrefs(t *testing.T) {
name: "enable_ssh",
flags: []string{"--ssh"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
wantJustEditMP: &ipn.MaskedPrefs{
RunSSHSet: true,
@@ -1132,12 +1131,12 @@ func TestUpdatePrefs(t *testing.T) {
name: "disable_ssh",
flags: []string{"--ssh=false"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
RunSSH: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
CorpDNS: true,
RunSSH: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
wantJustEditMP: &ipn.MaskedPrefs{
RunSSHSet: true,
@@ -1157,12 +1156,12 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--ssh=false"},
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
RunSSH: true,
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
RunSSH: true,
NoStatefulFiltering: opt.NewBool(true),
},
wantJustEditMP: &ipn.MaskedPrefs{
RunSSHSet: true,
@@ -1181,11 +1180,11 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--ssh=true"},
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
wantJustEditMP: &ipn.MaskedPrefs{
RunSSHSet: true,
@@ -1204,11 +1203,11 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--ssh=true", "--accept-risk=lose-ssh"},
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
wantJustEditMP: &ipn.MaskedPrefs{
RunSSHSet: true,
@@ -1226,12 +1225,12 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--ssh=false", "--accept-risk=lose-ssh"},
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
RunSSH: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
CorpDNS: true,
RunSSH: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
wantJustEditMP: &ipn.MaskedPrefs{
RunSSHSet: true,
@@ -1249,10 +1248,10 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--force-reauth"},
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
env: upCheckEnv{backendState: "Running"},
wantErrSubtr: "aborted, no changes made",
@@ -1262,10 +1261,10 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--force-reauth", "--accept-risk=lose-ssh"},
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
wantJustEditMP: nil,
env: upCheckEnv{backendState: "Running"},
@@ -1274,10 +1273,10 @@ func TestUpdatePrefs(t *testing.T) {
name: "advertise_connector",
flags: []string{"--advertise-connector"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(true),
},
wantJustEditMP: &ipn.MaskedPrefs{
AppConnectorSet: true,
@@ -1294,13 +1293,13 @@ func TestUpdatePrefs(t *testing.T) {
name: "no_advertise_connector",
flags: []string{"--advertise-connector=false"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ControlURL: ipn.DefaultControlURL,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AppConnector: ipn.AppConnectorPrefs{
Advertise: true,
},
NoStatefulFiltering: opt.NewBool(true),
},
wantJustEditMP: &ipn.MaskedPrefs{
AppConnectorSet: true,

View File

@@ -127,13 +127,13 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
printf("\nReport:\n")
printf("\t* UDP: %v\n", report.UDP)
if report.GlobalV4 != "" {
printf("\t* IPv4: yes, %v\n", report.GlobalV4)
if report.GlobalV4.IsValid() {
printf("\t* IPv4: yes, %s\n", report.GlobalV4)
} else {
printf("\t* IPv4: (no addr found)\n")
}
if report.GlobalV6 != "" {
printf("\t* IPv6: yes, %v\n", report.GlobalV6)
if report.GlobalV6.IsValid() {
printf("\t* IPv6: yes, %s\n", report.GlobalV6)
} else if report.IPv6 {
printf("\t* IPv6: (no addr found)\n")
} else if report.OSHasIPv6 {
@@ -142,7 +142,6 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
printf("\t* IPv6: no, unavailable in OS\n")
}
printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
printf("\t* HairPinning: %v\n", report.HairPinning)
printf("\t* PortMapping: %v\n", portMapping(report))
if report.CaptivePortal != "" {
printf("\t* CaptivePortal: %v\n", report.CaptivePortal)

View File

@@ -222,7 +222,8 @@ func runNetworkLockStatus(ctx context.Context, args []string) error {
if st.Enabled && st.NodeKey != nil && !st.PublicKey.IsZero() {
if st.NodeKeySigned {
fmt.Println("This node is accessible under tailnet lock.")
fmt.Println("This node is accessible under tailnet lock. Node signature:")
fmt.Println(st.NodeKeySignature.String())
} else {
fmt.Println("This node is LOCKED OUT by tailnet-lock, and action is required to establish connectivity.")
fmt.Printf("Run the following command on a node with a trusted key:\n\ttailscale lock sign %v %s\n", st.NodeKey, st.PublicKey.CLIString())

View File

@@ -103,7 +103,7 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
switch goos {
case "linux":
setf.BoolVar(&setArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes")
setf.BoolVar(&setArgs.statefulFiltering, "stateful-filtering", true, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, etc.)")
setf.BoolVar(&setArgs.statefulFiltering, "stateful-filtering", false, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, etc.)")
setf.StringVar(&setArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)")
case "windows":
setf.BoolVar(&setArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)")

View File

@@ -104,7 +104,7 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server")
upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes")
upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel")
upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, hidden+"install host routes to other Tailscale nodes")
upf.Var(notFalseVar{}, "host-routes", hidden+"install host routes to other Tailscale nodes (must be true as of Tailscale 1.67+)")
upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node")
upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
@@ -121,7 +121,7 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
switch goos {
case "linux":
upf.BoolVar(&upArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes")
upf.BoolVar(&upArgs.statefulFiltering, "stateful-filtering", true, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, etc.)")
upf.BoolVar(&upArgs.statefulFiltering, "stateful-filtering", false, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, etc.)")
upf.StringVar(&upArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)")
case "windows":
upf.BoolVar(&upArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)")
@@ -143,6 +143,18 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
return upf
}
// notFalseVar is is a flag.Value that can only be "true", if set.
type notFalseVar struct{}
func (notFalseVar) IsBoolFlag() bool { return true }
func (notFalseVar) Set(v string) error {
if v != "true" {
return fmt.Errorf("unsupported value; only 'true' is allowed")
}
return nil
}
func (notFalseVar) String() string { return "true" }
func defaultNetfilterMode() string {
if distro.Get() == distro.Synology {
return "off"
@@ -156,7 +168,6 @@ type upArgsT struct {
server string
acceptRoutes bool
acceptDNS bool
singleRoutes bool
exitNodeIP string
exitNodeAllowLANAccess bool
shieldsUp bool
@@ -278,7 +289,6 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
prefs.ExitNodeAllowLANAccess = upArgs.exitNodeAllowLANAccess
prefs.CorpDNS = upArgs.acceptDNS
prefs.AllowSingleHosts = upArgs.singleRoutes
prefs.ShieldsUp = upArgs.shieldsUp
prefs.RunSSH = upArgs.runSSH
prefs.RunWebClient = upArgs.runWebClient
@@ -740,7 +750,6 @@ func init() {
addPrefFlagMapping("accept-dns", "CorpDNS")
addPrefFlagMapping("accept-routes", "RouteAll")
addPrefFlagMapping("advertise-tags", "AdvertiseTags")
addPrefFlagMapping("host-routes", "AllowSingleHosts")
addPrefFlagMapping("hostname", "Hostname")
addPrefFlagMapping("login-server", "ControlURL")
addPrefFlagMapping("netfilter-mode", "NetfilterMode")
@@ -779,7 +788,7 @@ func addPrefFlagMapping(flagName string, prefNames ...string) {
// correspond to an ipn.Pref.
func preflessFlag(flagName string) bool {
switch flagName {
case "auth-key", "force-reauth", "reset", "qr", "json", "timeout", "accept-risk":
case "auth-key", "force-reauth", "reset", "qr", "json", "timeout", "accept-risk", "host-routes":
return true
}
return false
@@ -876,11 +885,26 @@ func checkForAccidentalSettingReverts(newPrefs, curPrefs *ipn.Prefs, env upCheck
// Issue 6811. Ignore on Synology.
continue
}
if flagName == "stateful-filtering" && valCur == true && valNew == false && env.goos == "linux" {
// See https://github.com/tailscale/tailscale/issues/12307
// Stateful filtering was on by default in tailscale 1.66.0-1.66.3, then off in 1.66.4.
// This broke Tailscale installations in containerized
// environments that use the default containerboot
// configuration that configures tailscale using
// 'tailscale up' command, which requires that all
// previously set flags are explicitly provided on
// subsequent restarts.
continue
}
missing = append(missing, fmtFlagValueArg(flagName, valCur))
}
if len(missing) == 0 {
return nil
}
// Some previously provided flags are missing. This run of 'tailscale
// up' will error out.
sort.Strings(missing)
// Compute the stringification of the explicitly provided args in flagSet
@@ -975,8 +999,6 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) {
set(prefs.ControlURL)
case "accept-routes":
set(prefs.RouteAll)
case "host-routes":
set(prefs.AllowSingleHosts)
case "accept-dns":
set(prefs.CorpDNS)
case "shields-up":

View File

@@ -299,7 +299,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
regexp from github.com/coreos/go-iptables/iptables+
regexp/syntax from regexp
runtime/debug from nhooyr.io/websocket/internal/xsync+
runtime/trace from testing
slices from tailscale.com/client/web+
sort from archive/tar+
strconv from archive/tar+
@@ -307,7 +306,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
sync from archive/tar+
sync/atomic from context+
syscall from archive/tar+
testing from tailscale.com/util/syspolicy
text/tabwriter from github.com/peterbourgon/ff/v3/ffcli+
text/template from html/template
text/template/parse from html/template+

View File

@@ -12,6 +12,7 @@ import (
func TestDeps(t *testing.T) {
deptest.DepChecker{
BadDeps: map[string]string{
"testing": "do not use testing package in production code",
"gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",

View File

@@ -144,6 +144,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf+
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
@@ -319,6 +320,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+
tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
tailscale.com/omit from tailscale.com/ipn/conffile
tailscale.com/paths from tailscale.com/client/tailscale+
💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal
tailscale.com/posture from tailscale.com/ipn/ipnlocal
@@ -439,7 +441,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/net/http2 from golang.org/x/net/http2/h2c+
golang.org/x/net/http2/h2c from tailscale.com/ipn/ipnlocal
golang.org/x/net/http2/hpack from golang.org/x/net/http2+
golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/icmp from tailscale.com/net/ping+
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
golang.org/x/net/ipv4 from github.com/miekg/dns+
golang.org/x/net/ipv6 from github.com/miekg/dns+
@@ -552,7 +554,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
regexp/syntax from regexp
runtime/debug from github.com/aws/aws-sdk-go-v2/internal/sync/singleflight+
runtime/pprof from net/http/pprof+
runtime/trace from net/http/pprof+
runtime/trace from net/http/pprof
slices from tailscale.com/appc+
sort from archive/tar+
strconv from archive/tar+
@@ -560,7 +562,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
sync from archive/tar+
sync/atomic from context+
syscall from archive/tar+
testing from tailscale.com/util/syspolicy
text/tabwriter from runtime/pprof
text/template from html/template
text/template/parse from html/template+

View File

@@ -118,7 +118,7 @@ var args struct {
tunname string
cleanUp bool
confFile string
confFile string // empty, file path, or "vm:user-data"
debug string
port uint16
statepath string
@@ -169,7 +169,7 @@ func main() {
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
flag.BoolVar(&printVersion, "version", false, "print version information and exit")
flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support")
flag.StringVar(&args.confFile, "config", "", "path to config file")
flag.StringVar(&args.confFile, "config", "", "path to config file, or 'vm:user-data' to use the VM's user-data (EC2)")
if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "tailscale" && beCLI != nil {
beCLI()
@@ -548,14 +548,25 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
return ok
}
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
// Note: don't just return ns.DialContextTCP or we'll
// return an interface containing a nil pointer.
// Note: don't just return ns.DialContextTCP or we'll return
// *gonet.TCPConn(nil) instead of a nil interface which trips up
// callers.
tcpConn, err := ns.DialContextTCP(ctx, dst)
if err != nil {
return nil, err
}
return tcpConn, nil
}
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
// Note: don't just return ns.DialContextUDP or we'll return
// *gonet.UDPConn(nil) instead of a nil interface which trips up
// callers.
udpConn, err := ns.DialContextUDP(ctx, dst)
if err != nil {
return nil, err
}
return udpConn, nil
}
}
if socksListener != nil || httpProxyListener != nil {
var addrs []string

View File

@@ -20,6 +20,7 @@ func TestDeps(t *testing.T) {
GOOS: "darwin",
GOARCH: "arm64",
BadDeps: map[string]string{
"testing": "do not use testing package in production code",
"gvisor.dev/gvisor/pkg/hostarch": "will crash on non-4K page sizes; see https://github.com/tailscale/tailscale/issues/8658",
},
}.Check(t)
@@ -28,6 +29,7 @@ func TestDeps(t *testing.T) {
GOOS: "linux",
GOARCH: "arm64",
BadDeps: map[string]string{
"testing": "do not use testing package in production code",
"gvisor.dev/gvisor/pkg/hostarch": "will crash on non-4K page sizes; see https://github.com/tailscale/tailscale/issues/8658",
},
}.Check(t)

View File

@@ -298,11 +298,10 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
go func() {
err := i.lb.Start(ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: i.controlURL,
RouteAll: false,
AllowSingleHosts: true,
WantRunning: true,
Hostname: i.hostname,
ControlURL: i.controlURL,
RouteAll: false,
WantRunning: true,
Hostname: i.hostname,
},
AuthKey: i.authKey,
})

View File

@@ -26,9 +26,8 @@ import (
type LoginGoal struct {
_ structs.Incomparable
token *tailcfg.Oauth2Token // oauth token to use when logging in
flags LoginFlags // flags to use when logging in
url string // auth url that needs to be visited
flags LoginFlags // flags to use when logging in
url string // auth url that needs to be visited
}
var _ Client = (*Auto)(nil)
@@ -338,7 +337,7 @@ func (c *Auto) authRoutine() {
url, err = c.direct.WaitLoginURL(ctx, goal.url)
f = "WaitLoginURL"
} else {
url, err = c.direct.TryLogin(ctx, goal.token, goal.flags)
url, err = c.direct.TryLogin(ctx, goal.flags)
f = "TryLogin"
}
if err != nil {
@@ -612,8 +611,8 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
})
}
func (c *Auto) Login(t *tailcfg.Oauth2Token, flags LoginFlags) {
c.logf("client.Login(%v, %v)", t != nil, flags)
func (c *Auto) Login(flags LoginFlags) {
c.logf("client.Login(%v)", flags)
c.mu.Lock()
defer c.mu.Unlock()
@@ -625,7 +624,6 @@ func (c *Auto) Login(t *tailcfg.Oauth2Token, flags LoginFlags) {
}
c.wantLoggedIn = true
c.loginGoal = &LoginGoal{
token: t,
flags: flags,
}
c.cancelMapCtxLocked()

View File

@@ -45,7 +45,7 @@ type Client interface {
// LoginFinished flag (on success) or an auth URL (if further
// interaction is needed). It merely sets the process in motion,
// and doesn't wait for it to complete.
Login(*tailcfg.Oauth2Token, LoginFlags)
Login(LoginFlags)
// Logout starts a synchronous logout process. It doesn't return
// until the logout operation has been completed.
Logout(context.Context) error

View File

@@ -401,12 +401,12 @@ func (c *Direct) TryLogout(ctx context.Context) error {
return err
}
func (c *Direct) TryLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags LoginFlags) (url string, err error) {
func (c *Direct) TryLogin(ctx context.Context, flags LoginFlags) (url string, err error) {
if strings.Contains(c.serverURL, "controlplane.tailscale.com") && envknob.Bool("TS_PANIC_IF_HIT_MAIN_CONTROL") {
panic(fmt.Sprintf("[unexpected] controlclient: TryLogin called on %s; tainted=%v", c.serverURL, c.panicOnUse))
}
c.logf("[v1] direct.TryLogin(token=%v, flags=%v)", t != nil, flags)
return c.doLoginOrRegen(ctx, loginOpt{Token: t, Flags: flags})
c.logf("[v1] direct.TryLogin(flags=%v)", flags)
return c.doLoginOrRegen(ctx, loginOpt{Flags: flags})
}
// WaitLoginURL sits in a long poll waiting for the user to authenticate at url.
@@ -441,7 +441,6 @@ func (c *Direct) SetExpirySooner(ctx context.Context, expiry time.Time) error {
}
type loginOpt struct {
Token *tailcfg.Oauth2Token
Flags LoginFlags
Regen bool // generate a new nodekey, can be overridden in doLogin
URL string
@@ -559,7 +558,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
var nodeKeySignature tkatype.MarshaledSignature
if !oldNodeKey.IsZero() && opt.OldNodeKeySignature != nil {
if nodeKeySignature, err = resignNKS(persist.NetworkLockKey, tryingNewKey.Public(), opt.OldNodeKeySignature); err != nil {
if nodeKeySignature, err = tka.ResignNKS(persist.NetworkLockKey, tryingNewKey.Public(), opt.OldNodeKeySignature); err != nil {
c.logf("Failed re-signing node-key signature: %v", err)
}
} else if isWrapped {
@@ -610,10 +609,9 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
c.logf("RegisterReq: onode=%v node=%v fup=%v nks=%v",
request.OldNodeKey.ShortString(),
request.NodeKey.ShortString(), opt.URL != "", len(nodeKeySignature) > 0)
if opt.Token != nil || authKey != "" {
if authKey != "" {
request.Auth = &tailcfg.RegisterResponseAuth{
Oauth2Token: opt.Token,
AuthKey: authKey,
AuthKey: authKey,
}
}
err = signRegisterRequest(&request, c.serverURL, c.serverLegacyKey, machinePrivKey.Public())
@@ -731,45 +729,6 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
return false, resp.AuthURL, nil, nil
}
// resignNKS re-signs a node-key signature for a new node-key.
//
// This only matters on network-locked tailnets, because node-key signatures are
// how other nodes know that a node-key is authentic. When the node-key is
// rotated then the existing signature becomes invalid, so this function is
// responsible for generating a new wrapping signature to certify the new node-key.
//
// The signature itself is a SigRotation signature, which embeds the old signature
// and certifies the new node-key as a replacement for the old by signing the new
// signature with RotationPubkey (which is the node's own network-lock key).
func resignNKS(priv key.NLPrivate, nodeKey key.NodePublic, oldNKS tkatype.MarshaledSignature) (tkatype.MarshaledSignature, error) {
var oldSig tka.NodeKeySignature
if err := oldSig.Unserialize(oldNKS); err != nil {
return nil, fmt.Errorf("decoding NKS: %w", err)
}
nk, err := nodeKey.MarshalBinary()
if err != nil {
return nil, fmt.Errorf("marshalling node-key: %w", err)
}
if bytes.Equal(nk, oldSig.Pubkey) {
// The old signature is valid for the node-key we are using, so just
// use it verbatim.
return oldNKS, nil
}
newSig := tka.NodeKeySignature{
SigKind: tka.SigRotation,
Pubkey: nk,
Nested: &oldSig,
}
if newSig.Signature, err = priv.SignNKS(newSig.SigHash()); err != nil {
return nil, fmt.Errorf("signing NKS: %w", err)
}
return newSig.Serialize(), nil
}
// newEndpoints acquires c.mu and sets the local port and endpoints and reports
// whether they've changed.
//

View File

@@ -329,20 +329,36 @@ func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
s.initMetacert()
s.packetsRecvDisco = s.packetsRecvByKind.Get("disco")
s.packetsRecvOther = s.packetsRecvByKind.Get("other")
s.packetsDroppedReasonCounters = []*expvar.Int{
s.packetsDroppedReason.Get("unknown_dest"),
s.packetsDroppedReason.Get("unknown_dest_on_fwd"),
s.packetsDroppedReason.Get("gone_disconnected"),
s.packetsDroppedReason.Get("gone_not_here"),
s.packetsDroppedReason.Get("queue_head"),
s.packetsDroppedReason.Get("queue_tail"),
s.packetsDroppedReason.Get("write_error"),
}
s.packetsDroppedReasonCounters = s.genPacketsDroppedReasonCounters()
s.packetsDroppedTypeDisco = s.packetsDroppedType.Get("disco")
s.packetsDroppedTypeOther = s.packetsDroppedType.Get("other")
return s
}
func (s *Server) genPacketsDroppedReasonCounters() []*expvar.Int {
getMetric := s.packetsDroppedReason.Get
ret := []*expvar.Int{
dropReasonUnknownDest: getMetric("unknown_dest"),
dropReasonUnknownDestOnFwd: getMetric("unknown_dest_on_fwd"),
dropReasonGoneDisconnected: getMetric("gone_disconnected"),
dropReasonQueueHead: getMetric("queue_head"),
dropReasonQueueTail: getMetric("queue_tail"),
dropReasonWriteError: getMetric("write_error"),
dropReasonDupClient: getMetric("dup_client"),
}
if len(ret) != int(numDropReasons) {
panic("dropReason metrics out of sync")
}
for i := range numDropReasons {
if ret[i] == nil {
panic("dropReason metrics out of sync")
}
}
return ret
}
// SetMesh sets the pre-shared key that regional DERP servers used to mesh
// amongst themselves.
//
@@ -1047,6 +1063,7 @@ const (
dropReasonQueueTail // destination queue is full, dropped packet at queue tail
dropReasonWriteError // OS write() failed
dropReasonDupClient // the public key is connected 2+ times (active/active, fighting)
numDropReasons // unused; keep last
)
func (s *Server) recordDrop(packetBytes []byte, srcKey, dstKey key.NodePublic, reason dropReason) {

View File

@@ -18,11 +18,12 @@ func _() {
_ = x[dropReasonQueueTail-4]
_ = x[dropReasonWriteError-5]
_ = x[dropReasonDupClient-6]
_ = x[numDropReasons-7]
}
const _dropReason_name = "UnknownDestUnknownDestOnFwdGoneDisconnectedQueueHeadQueueTailWriteErrorDupClient"
const _dropReason_name = "UnknownDestUnknownDestOnFwdGoneDisconnectedQueueHeadQueueTailWriteErrorDupClientnumDropReasons"
var _dropReason_index = [...]uint8{0, 11, 27, 43, 52, 61, 71, 80}
var _dropReason_index = [...]uint8{0, 11, 27, 43, 52, 61, 71, 80, 94}
func (i dropReason) String() string {
if i < 0 || i >= dropReason(len(_dropReason_index)-1) {

View File

@@ -29,5 +29,6 @@ spec:
- name: TS_ROUTES
value: "{{TS_ROUTES}}"
securityContext:
runAsUser: 1000
runAsGroup: 1000
capabilities:
add:
- NET_ADMIN

View File

@@ -93,8 +93,15 @@ var cacheInvalidatingMethods = map[string]bool{
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "PROPFIND" {
h.handlePROPFIND(w, r)
pathComponents := shared.CleanAndSplit(r.URL.Path)
mpl := h.maxPathLength(r)
switch r.Method {
case "PROPFIND":
h.handlePROPFIND(w, r, pathComponents, mpl)
return
case "LOCK":
h.handleLOCK(w, r, pathComponents, mpl)
return
}
@@ -107,9 +114,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.StatCache.invalidate()
}
mpl := h.maxPathLength(r)
pathComponents := shared.CleanAndSplit(r.URL.Path)
if len(pathComponents) >= mpl {
h.delegate(mpl, pathComponents[mpl-1:], w, r)
return
@@ -141,6 +145,8 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request) {
// delegate sends the request to the Child WebDAV server.
func (h *Handler) delegate(mpl int, pathComponents []string, w http.ResponseWriter, r *http.Request) {
rewriteIfHeader(r, pathComponents, mpl)
dest := r.Header.Get("Destination")
if dest != "" {
// Rewrite destination header

View File

@@ -1,77 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositedav
import (
"bytes"
"fmt"
"math"
"net/http"
"regexp"
"tailscale.com/drive/driveimpl/shared"
)
var (
hrefRegex = regexp.MustCompile(`(?s)<D:href>/?([^<]*)/?</D:href>`)
)
func (h *Handler) handlePROPFIND(w http.ResponseWriter, r *http.Request) {
pathComponents := shared.CleanAndSplit(r.URL.Path)
mpl := h.maxPathLength(r)
if !shared.IsRoot(r.URL.Path) && len(pathComponents)+getDepth(r) > mpl {
// Delegate to a Child.
depth := getDepth(r)
status, result := h.StatCache.getOr(r.URL.Path, depth, func() (int, []byte) {
// Use a buffering ResponseWriter so that we can manipulate the result.
// The only thing we use from the original ResponseWriter is Header().
bw := &bufferingResponseWriter{ResponseWriter: w}
mpl := h.maxPathLength(r)
h.delegate(mpl, pathComponents[mpl-1:], bw, r)
// Fixup paths to add the requested path as a prefix.
pathPrefix := shared.Join(pathComponents[0:mpl]...)
b := hrefRegex.ReplaceAll(bw.buf.Bytes(), []byte(fmt.Sprintf("<D:href>%s/$1</D:href>", pathPrefix)))
return bw.status, b
})
w.Header().Del("Content-Length")
w.WriteHeader(status)
if result != nil {
w.Write(result)
}
return
}
h.handle(w, r)
}
func getDepth(r *http.Request) int {
switch r.Header.Get("Depth") {
case "0":
return 0
case "1":
return 1
case "infinity":
return math.MaxInt
}
return 0
}
type bufferingResponseWriter struct {
http.ResponseWriter
status int
buf bytes.Buffer
}
func (bw *bufferingResponseWriter) WriteHeader(statusCode int) {
bw.status = statusCode
}
func (bw *bufferingResponseWriter) Write(p []byte) (int, error) {
return bw.buf.Write(p)
}

View File

@@ -0,0 +1,122 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositedav
import (
"bytes"
"fmt"
"math"
"net/http"
"regexp"
"strings"
"tailscale.com/drive/driveimpl/shared"
)
var (
responseHrefRegex = regexp.MustCompile(`(?s)(<D:(response|lockroot)>)<D:href>/?([^<]*)/?</D:href>`)
ifHrefRegex = regexp.MustCompile(`^<(https?://[^/]+)?([^>]+)>`)
)
func (h *Handler) handlePROPFIND(w http.ResponseWriter, r *http.Request, pathComponents []string, mpl int) {
if shouldDelegateToChild(r, pathComponents, mpl) {
// Delegate to a Child.
depth := getDepth(r)
status, result := h.StatCache.getOr(r.URL.Path, depth, func() (int, []byte) {
return h.delegateRewriting(w, r, pathComponents, mpl)
})
respondRewritten(w, status, result)
return
}
h.handle(w, r)
}
func (h *Handler) handleLOCK(w http.ResponseWriter, r *http.Request, pathComponents []string, mpl int) {
if shouldDelegateToChild(r, pathComponents, mpl) {
// Delegate to a Child.
status, result := h.delegateRewriting(w, r, pathComponents, mpl)
respondRewritten(w, status, result)
return
}
http.Error(w, "locking of top level directories is not allowed", http.StatusMethodNotAllowed)
}
// shouldDelegateToChild decides whether a request should be delegated to a
// child filesystem, as opposed to being handled by this filesystem. It checks
// the depth of the requested path, and if it's deeper than the portion of the
// tree that's handled by the parent, returns true.
func shouldDelegateToChild(r *http.Request, pathComponents []string, mpl int) bool {
return !shared.IsRoot(r.URL.Path) && len(pathComponents)+getDepth(r) > mpl
}
func (h *Handler) delegateRewriting(w http.ResponseWriter, r *http.Request, pathComponents []string, mpl int) (int, []byte) {
// Use a buffering ResponseWriter so that we can manipulate the result.
// The only thing we use from the original ResponseWriter is Header().
bw := &bufferingResponseWriter{ResponseWriter: w}
h.delegate(mpl, pathComponents[mpl-1:], bw, r)
// Fixup paths to add the requested path as a prefix, escaped for inclusion in XML.
pp := shared.EscapeForXML(shared.Join(pathComponents[0:mpl]...))
b := responseHrefRegex.ReplaceAll(bw.buf.Bytes(), []byte(fmt.Sprintf("$1<D:href>%s/$3</D:href>", pp)))
return bw.status, b
}
func respondRewritten(w http.ResponseWriter, status int, result []byte) {
w.Header().Del("Content-Length")
w.WriteHeader(status)
if result != nil {
w.Write(result)
}
}
func getDepth(r *http.Request) int {
switch r.Header.Get("Depth") {
case "0":
return 0
case "1":
return 1
case "infinity":
return math.MaxInt16 // a really large number, but not infinity (avoids wrapping when we do arithmetic with this)
}
return 0
}
type bufferingResponseWriter struct {
http.ResponseWriter
status int
buf bytes.Buffer
}
func (bw *bufferingResponseWriter) WriteHeader(statusCode int) {
bw.status = statusCode
}
func (bw *bufferingResponseWriter) Write(p []byte) (int, error) {
return bw.buf.Write(p)
}
// rewriteIfHeader rewrites URLs in the If header by removing the host and the
// portion of the path that corresponds to this composite filesystem. This way,
// when we delegate requests to child filesystems, the If header will reference
// a path that makes sense on those filesystems.
//
// See http://www.webdav.org/specs/rfc4918.html#HEADER_If
func rewriteIfHeader(r *http.Request, pathComponents []string, mpl int) {
ih := r.Header.Get("If")
if ih == "" {
return
}
matches := ifHrefRegex.FindStringSubmatch(ih)
if len(matches) == 3 {
pp := shared.JoinEscaped(pathComponents[0:mpl]...)
p := strings.Replace(shared.JoinEscaped(pathComponents...), pp, "", 1)
nih := ifHrefRegex.ReplaceAllString(ih, fmt.Sprintf("<%s>", p))
r.Header.Set("If", nih)
}
}

View File

@@ -14,6 +14,8 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
@@ -30,14 +32,29 @@ import (
const (
domain = `test$%domain.com`
remote1 = `rem ote$%1`
remote2 = `_rem ote$%2`
share11 = `sha re$%11`
share12 = `_sha re$%12`
file111 = `fi le$%111.txt`
remote1 = `rem ote$%<>1`
remote2 = `_rem ote$%<>2`
share11 = `sha re$%<>11`
share12 = `_sha re$%<>12`
file112 = `file112.txt`
)
var (
file111 = `fi le$%<>111.txt`
)
func init() {
if runtime.GOOS == "windows" {
// file with less than and greater than doesn't work on Windows
file111 = `fi le$%111.txt`
}
}
var (
lockRootRegex = regexp.MustCompile(`<D:lockroot><D:href>/?([^<]*)/?</D:href>`)
lockTokenRegex = regexp.MustCompile(`<D:locktoken><D:href>([0-9]+)/?</D:href>`)
)
func init() {
// set AllowShareAs() to false so that we don't try to use sub-processes
// for access files on disk.
@@ -145,6 +162,206 @@ func TestSecretTokenAuth(t *testing.T) {
}
}
func TestLOCK(t *testing.T) {
s := newSystem(t)
s.addRemote(remote1)
s.addShare(remote1, share11, drive.PermissionReadWrite)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
client := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
}
u := fmt.Sprintf("http://%s/%s/%s/%s/%s",
s.local.l.Addr(),
url.PathEscape(domain),
url.PathEscape(remote1),
url.PathEscape(share11),
url.PathEscape(file111))
// First acquire a lock with a short timeout
req, err := http.NewRequest("LOCK", u, strings.NewReader(lockBody))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Depth", "infinity")
req.Header.Set("Timeout", "Second-1")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected LOCK to succeed, but got status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
submatches := lockRootRegex.FindStringSubmatch(string(body))
if len(submatches) != 2 {
t.Fatal("failed to find lockroot")
}
want := shared.EscapeForXML(pathTo(remote1, share11, file111))
got := submatches[1]
if got != want {
t.Fatalf("want lockroot %q, got %q", want, got)
}
submatches = lockTokenRegex.FindStringSubmatch(string(body))
if len(submatches) != 2 {
t.Fatal("failed to find locktoken")
}
lockToken := submatches[1]
ifHeader := fmt.Sprintf("<%s> (<%s>)", u, lockToken)
// Then refresh the lock with a longer timeout
req, err = http.NewRequest("LOCK", u, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Depth", "infinity")
req.Header.Set("Timeout", "Second-600")
req.Header.Set("If", ifHeader)
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected LOCK refresh to succeed, but got status %d", resp.StatusCode)
}
body, err = io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
submatches = lockRootRegex.FindStringSubmatch(string(body))
if len(submatches) != 2 {
t.Fatal("failed to find lockroot after refresh")
}
want = shared.EscapeForXML(pathTo(remote1, share11, file111))
got = submatches[1]
if got != want {
t.Fatalf("want lockroot after refresh %q, got %q", want, got)
}
submatches = lockTokenRegex.FindStringSubmatch(string(body))
if len(submatches) != 2 {
t.Fatal("failed to find locktoken after refresh")
}
if submatches[1] != lockToken {
t.Fatalf("on refresh, lock token changed from %q to %q", lockToken, submatches[1])
}
// Then wait past the original timeout, then try to delete without the lock
// (should fail)
time.Sleep(1 * time.Second)
req, err = http.NewRequest("DELETE", u, nil)
if err != nil {
log.Fatal(err)
}
resp, err = client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 423 {
t.Fatalf("deleting without lock token should fail with 423, but got %d", resp.StatusCode)
}
// Then delete with the lock (should succeed)
req, err = http.NewRequest("DELETE", u, nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("If", ifHeader)
resp, err = client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 204 {
t.Fatalf("deleting with lock token should have succeeded with 204, but got %d", resp.StatusCode)
}
}
func TestUNLOCK(t *testing.T) {
s := newSystem(t)
s.addRemote(remote1)
s.addShare(remote1, share11, drive.PermissionReadWrite)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
client := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
}
u := fmt.Sprintf("http://%s/%s/%s/%s/%s",
s.local.l.Addr(),
url.PathEscape(domain),
url.PathEscape(remote1),
url.PathEscape(share11),
url.PathEscape(file111))
// Acquire a lock
req, err := http.NewRequest("LOCK", u, strings.NewReader(lockBody))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Depth", "infinity")
req.Header.Set("Timeout", "Second-600")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected LOCK to succeed, but got status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
submatches := lockTokenRegex.FindStringSubmatch(string(body))
if len(submatches) != 2 {
t.Fatal("failed to find locktoken")
}
lockToken := submatches[1]
// Release the lock
req, err = http.NewRequest("UNLOCK", u, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Lock-Token", fmt.Sprintf("<%s>", lockToken))
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 204 {
t.Fatalf("expected UNLOCK to succeed with a 204, but got status %d", resp.StatusCode)
}
// Then delete without the lock (should succeed)
req, err = http.NewRequest("DELETE", u, nil)
if err != nil {
log.Fatal(err)
}
resp, err = client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 204 {
t.Fatalf("deleting without lock should have succeeded with 204, but got %d", resp.StatusCode)
}
}
type local struct {
l net.Listener
fs *FileSystemForLocal
@@ -486,3 +703,9 @@ func (a *noopAuthenticator) Clone() gowebdav.Authenticator {
func (a *noopAuthenticator) Close() error {
return nil
}
const lockBody = `<?xml version="1.0" encoding="utf-8" ?>
<D:lockinfo xmlns:D='DAV:'>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockinfo>`

View File

@@ -151,6 +151,9 @@ func (s *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
return
}
// WebDAV's locking code compares the lock resources with the request's
// host header, set this to empty to avoid mismatches.
r.Host = ""
h.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package shared
import (
"bytes"
"encoding/xml"
)
// EscapeForXML escapes the given string for use in XML text.
func EscapeForXML(s string) string {
result := bytes.NewBuffer(nil)
xml.Escape(result, []byte(s))
return result.String()
}

4
go.mod
View File

@@ -14,6 +14,7 @@ require (
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64
github.com/aws/aws-sdk-go-v2/service/s3 v1.33.0
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7
github.com/bramvdbogaerde/go-scp v1.4.0
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/creack/pty v1.1.21
@@ -37,7 +38,7 @@ require (
github.com/google/go-cmp v0.6.0
github.com/google/go-containerregistry v0.18.0
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806
github.com/google/uuid v1.5.0
github.com/google/uuid v1.6.0
github.com/goreleaser/nfpm/v2 v2.33.1
github.com/hdevalence/ed25519consensus v0.2.0
github.com/iancoleman/strcase v0.3.0
@@ -60,6 +61,7 @@ require (
github.com/peterbourgon/ff/v3 v3.4.0
github.com/pkg/errors v0.9.1
github.com/pkg/sftp v1.13.6
github.com/prometheus-community/pro-bing v0.4.0
github.com/prometheus/client_golang v1.18.0
github.com/prometheus/common v0.46.0
github.com/safchain/ethtool v0.3.0

8
go.sum
View File

@@ -177,6 +177,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v3 v3.4.0 h1:RkSxjT3tmlptwfgEgTgU+KYKLI35p/tviNXNXiL2aNU=
github.com/bombsimon/wsl/v3 v3.4.0/go.mod h1:KkIB+TXkqy6MvK9BDZVbZxKNYsE1/oLRJbIFtf14qqo=
github.com/bramvdbogaerde/go-scp v1.4.0 h1:jKMwpwCbcX1KyvDbm/PDJuXcMuNVlLGi0Q0reuzjyKY=
github.com/bramvdbogaerde/go-scp v1.4.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ=
github.com/breml/bidichk v0.2.4 h1:i3yedFWWQ7YzjdZJHnPo9d/xURinSq3OM+gyM43K4/8=
github.com/breml/bidichk v0.2.4/go.mod h1:7Zk0kRFt1LIZxtQdl9W9JwGAcLTTkOs+tN7wuEYGJ3s=
github.com/breml/errchkjson v0.3.1 h1:hlIeXuspTyt8Y/UmP5qy1JocGNR00KQHgfaNtRAjoxQ=
@@ -468,8 +470,8 @@ github.com/google/rpmpack v0.5.0 h1:L16KZ3QvkFGpYhmp23iQip+mx1X39foEsqszjMNBm8A=
github.com/google/rpmpack v0.5.0/go.mod h1:uqVAUVQLq8UY2hCDfmJ/+rtO3aw7qyhc90rCVEabEfI=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
@@ -731,6 +733,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polyfloyd/go-errorlint v1.4.1 h1:r8ru5FhXSn34YU1GJDOuoJv2LdsQkPmK325EOpPMJlM=
github.com/polyfloyd/go-errorlint v1.4.1/go.mod h1:k6fU/+fQe38ednoZS51T7gSIGQW1y94d6TkSr35OzH8=
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=

59
ipn/conffile/cloudconf.go Normal file
View File

@@ -0,0 +1,59 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package conffile
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"tailscale.com/omit"
)
func getEC2MetadataToken() (string, error) {
if omit.AWS {
return "", omit.Err
}
req, _ := http.NewRequest("PUT", "http://169.254.169.254/latest/api/token", nil)
req.Header.Add("X-aws-ec2-metadata-token-ttl-seconds", "300")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get metadata token: %w", err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
return "", fmt.Errorf("failed to get metadata token: %v", res.Status)
}
all, err := io.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("failed to read metadata token: %w", err)
}
return strings.TrimSpace(string(all)), nil
}
func readVMUserData() ([]byte, error) {
// TODO(bradfitz): support GCP, Azure, Proxmox/cloud-init
// (NoCloud/ConfigDrive ISO), etc.
if omit.AWS {
return nil, omit.Err
}
token, tokErr := getEC2MetadataToken()
req, _ := http.NewRequest("GET", "http://169.254.169.254/latest/user-data", nil)
req.Header.Add("X-aws-ec2-metadata-token", token)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
if tokErr != nil {
return nil, fmt.Errorf("failed to get VM user data: %v; also failed to get metadata token: %v", res.Status, tokErr)
}
return nil, errors.New(res.Status)
}
return io.ReadAll(res.Body)
}

View File

@@ -17,7 +17,7 @@ import (
// Config describes a config file.
type Config struct {
Path string // disk path of HuJSON
Path string // disk path of HuJSON, or VMUserDataPath
Raw []byte // raw bytes from disk, in HuJSON form
Std []byte // standardized JSON form
Version string // "alpha0" for now
@@ -35,13 +35,22 @@ func (c *Config) WantRunning() bool {
return c != nil && !c.Parsed.Enabled.EqualBool(false)
}
// VMUserDataPath is a sentinel value for Load to use to get the data
// from the VM's metadata service's user-data field.
const VMUserDataPath = "vm:user-data"
// Load reads and parses the config file at the provided path on disk.
func Load(path string) (*Config, error) {
var c Config
c.Path = path
var err error
c.Raw, err = os.ReadFile(path)
switch path {
case VMUserDataPath:
c.Raw, err = readVMUserData()
default:
c.Raw, err = os.ReadFile(path)
}
if err != nil {
return nil, err
}

View File

@@ -40,7 +40,6 @@ func (src *Prefs) Clone() *Prefs {
var _PrefsCloneNeedsRegeneration = Prefs(struct {
ControlURL string
RouteAll bool
AllowSingleHosts bool
ExitNodeID tailcfg.StableNodeID
ExitNodeIP netip.Addr
InternalExitNodePrior tailcfg.StableNodeID
@@ -67,6 +66,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
PostureChecking bool
NetfilterKind string
DriveShares []*drive.Share
AllowSingleHosts marshalAsTrueInJSON
Persist *persist.Persist
}{})

View File

@@ -12,6 +12,7 @@ import (
func TestDeps(t *testing.T) {
deptest.DepChecker{
BadDeps: map[string]string{
"testing": "do not use testing package in production code",
"gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",

View File

@@ -67,7 +67,6 @@ func (v *PrefsView) UnmarshalJSON(b []byte) error {
func (v PrefsView) ControlURL() string { return v.ж.ControlURL }
func (v PrefsView) RouteAll() bool { return v.ж.RouteAll }
func (v PrefsView) AllowSingleHosts() bool { return v.ж.AllowSingleHosts }
func (v PrefsView) ExitNodeID() tailcfg.StableNodeID { return v.ж.ExitNodeID }
func (v PrefsView) ExitNodeIP() netip.Addr { return v.ж.ExitNodeIP }
func (v PrefsView) InternalExitNodePrior() tailcfg.StableNodeID { return v.ж.InternalExitNodePrior }
@@ -98,13 +97,13 @@ func (v PrefsView) NetfilterKind() string { return v.ж.Netfilte
func (v PrefsView) DriveShares() views.SliceView[*drive.Share, drive.ShareView] {
return views.SliceOfViews[*drive.Share, drive.ShareView](v.ж.DriveShares)
}
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
func (v PrefsView) AllowSingleHosts() marshalAsTrueInJSON { return v.ж.AllowSingleHosts }
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _PrefsViewNeedsRegeneration = Prefs(struct {
ControlURL string
RouteAll bool
AllowSingleHosts bool
ExitNodeID tailcfg.StableNodeID
ExitNodeIP netip.Addr
InternalExitNodePrior tailcfg.StableNodeID
@@ -131,6 +130,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
PostureChecking bool
NetfilterKind string
DriveShares []*drive.Share
AllowSingleHosts marshalAsTrueInJSON
Persist *persist.Persist
}{})

View File

@@ -478,17 +478,44 @@ func findCmdTailscale() (string, error) {
}
func tailscaleUpdateCmd(cmdTS string) *exec.Cmd {
defaultCmd := exec.Command(cmdTS, "update", "--yes")
if runtime.GOOS != "linux" {
return exec.Command(cmdTS, "update", "--yes")
return defaultCmd
}
if _, err := exec.LookPath("systemd-run"); err != nil {
return exec.Command(cmdTS, "update", "--yes")
return defaultCmd
}
// When systemd-run is available, use it to run the update command. This
// creates a new temporary unit separate from the tailscaled unit. When
// tailscaled is restarted during the update, systemd won't kill this
// temporary update unit, which could cause unexpected breakage.
return exec.Command("systemd-run", "--wait", "--pipe", "--collect", cmdTS, "update", "--yes")
//
// We want to use the --wait flag for systemd-run, to block the update
// command until completion and collect output. But this flag was added in
// systemd 232, so we need to check the version first.
//
// The output will look like:
//
// systemd 255 (255.7-1-arch)
// +PAM +AUDIT ... other feature flags ...
systemdVerOut, err := exec.Command("systemd-run", "--version").Output()
if err != nil {
return defaultCmd
}
parts := strings.Fields(string(systemdVerOut))
if len(parts) < 2 || parts[0] != "systemd" {
return defaultCmd
}
systemdVer, err := strconv.Atoi(parts[1])
if err != nil {
return defaultCmd
}
if systemdVer < 232 {
return exec.Command("systemd-run", "--pipe", "--collect", cmdTS, "update", "--yes")
} else {
return exec.Command("systemd-run", "--wait", "--pipe", "--collect", cmdTS, "update", "--yes")
}
}
func regularFileExists(path string) bool {

View File

@@ -1842,7 +1842,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
// Without this, the state machine transitions to "NeedsLogin" implying
// that user interaction is required, which is not the case and can
// regress tsnet.Server restarts.
cc.Login(nil, controlclient.LoginDefault)
cc.Login(controlclient.LoginDefault)
}
b.stateMachineLockedOnEntry(unlock)
@@ -2825,7 +2825,7 @@ func (b *LocalBackend) StartLoginInteractive(ctx context.Context) error {
if url != "" && timeSinceAuthURLCreated < ((7*24*time.Hour)-(1*time.Hour)) {
b.popBrowserAuthNow()
} else {
cc.Login(nil, b.loginFlags|controlclient.LoginInteractive)
cc.Login(b.loginFlags | controlclient.LoginInteractive)
}
return nil
}
@@ -3339,7 +3339,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
if !oldp.WantRunning() && newp.WantRunning {
b.logf("transitioning to running; doing Login...")
cc.Login(nil, controlclient.LoginDefault)
cc.Login(controlclient.LoginDefault)
}
if oldp.WantRunning() != newp.WantRunning {
@@ -3649,9 +3649,6 @@ func (b *LocalBackend) authReconfig() {
if prefs.RouteAll() {
flags |= netmap.AllowSubnetRoutes
}
if prefs.AllowSingleHosts() {
flags |= netmap.AllowSingleHosts
}
if hasPAC && disableSubnetsIfPAC {
if flags&netmap.AllowSubnetRoutes != 0 {
b.logf("authReconfig: have PAC; disabling subnet routes")
@@ -4189,18 +4186,7 @@ func (b *LocalBackend) routerConfig(cfg *wgcfg.Config, prefs ipn.PrefsView, oneC
}
var doStatefulFiltering bool
if v, ok := prefs.NoStatefulFiltering().Get(); !ok {
// The stateful filtering preference isn't explicitly set; this is
// unexpected since we expect it to be set during the profile
// backfill, but to be safe let's enable stateful filtering
// absent further information.
doStatefulFiltering = true
b.logf("[unexpected] NoStatefulFiltering preference not set; enabling stateful filtering")
} else if v {
// The preferences explicitly say "no stateful filtering", so
// we don't do it.
doStatefulFiltering = false
} else {
if v, ok := prefs.NoStatefulFiltering().Get(); ok && !v {
// The preferences explicitly "do stateful filtering" is turned
// off, or to expand the double negative, to do stateful
// filtering. Do so.
@@ -6467,8 +6453,17 @@ func suggestExitNode(report *netcheck.Report, netMap *netmap.NetworkMap, r *rand
if report.PreferredDERP == 0 {
return res, ErrNoPreferredDERP
}
var allowedCandidates set.Set[string]
if allowed, err := syspolicy.GetStringArray(syspolicy.AllowedSuggestedExitNodes, nil); err != nil {
return res, fmt.Errorf("unable to read %s policy: %w", syspolicy.AllowedSuggestedExitNodes, err)
} else if allowed != nil && len(allowed) > 0 {
allowedCandidates = set.SetOf(allowed)
}
candidates := make([]tailcfg.NodeView, 0, len(netMap.Peers))
for _, peer := range netMap.Peers {
if allowedCandidates != nil && !allowedCandidates.Contains(string(peer.StableID())) {
continue
}
if peer.CapMap().Has(tailcfg.NodeAttrSuggestExitNode) && tsaddr.ContainsExitRoutes(peer.AllowedIPs()) {
candidates = append(candidates, peer)
}

View File

@@ -1595,6 +1595,9 @@ type mockSyspolicyHandler struct {
// queried by the current test. If the policy is expected but unset, then
// use nil, otherwise use a string equal to the policy's desired value.
stringPolicies map[syspolicy.Key]*string
// stringArrayPolicies is the collection of policies that we expected to see
// queries by the current test, that return policy string arrays.
stringArrayPolicies map[syspolicy.Key][]string
// failUnknownPolicies is set if policies other than those in stringPolicies
// (uint64 or bool policies are not supported by mockSyspolicyHandler yet)
// should be considered a test failure if they are queried.
@@ -1632,6 +1635,12 @@ func (h *mockSyspolicyHandler) ReadStringArray(key string) ([]string, error) {
if h.failUnknownPolicies {
h.t.Errorf("ReadStringArray(%q) unexpectedly called", key)
}
if s, ok := h.stringArrayPolicies[syspolicy.Key(key)]; ok {
if s == nil {
return []string{}, syspolicy.ErrNoSuchKey
}
return s, nil
}
return nil, syspolicy.ErrNoSuchKey
}
@@ -3474,6 +3483,7 @@ func TestLocalBackendSuggestExitNode(t *testing.T) {
lastSuggestedExitNode lastSuggestedExitNode
report *netcheck.Report
netMap netmap.NetworkMap
allowedSuggestedExitNodes []string
wantID tailcfg.StableNodeID
wantName string
wantErr error
@@ -3766,10 +3776,138 @@ func TestLocalBackendSuggestExitNode(t *testing.T) {
},
wantErr: ErrCannotSuggestExitNode,
},
{
name: "only pick from allowed suggested exit nodes",
lastSuggestedExitNode: lastSuggestedExitNode{name: "test", id: "test"},
report: &netcheck.Report{
RegionLatency: map[int]time.Duration{
1: 10,
2: 10,
3: 5,
},
PreferredDERP: 1,
},
netMap: netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Addresses: []netip.Prefix{
netip.MustParsePrefix("100.64.1.1/32"),
netip.MustParsePrefix("fe70::1/128"),
},
}).View(),
DERPMap: &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {},
2: {},
3: {},
},
},
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
ID: 2,
StableID: "test",
Name: "test",
DERP: "127.3.3.40:1",
AllowedIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
},
CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{
tailcfg.NodeAttrSuggestExitNode: {},
tailcfg.NodeAttrAutoExitNode: {},
}),
}).View(),
(&tailcfg.Node{
ID: 3,
StableID: "foo",
Name: "foo",
DERP: "127.3.3.40:3",
AllowedIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
},
CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{
tailcfg.NodeAttrSuggestExitNode: {},
tailcfg.NodeAttrAutoExitNode: {},
}),
}).View(),
},
},
allowedSuggestedExitNodes: []string{"test"},
wantID: "test",
wantName: "test",
wantLastSuggestedExitNode: lastSuggestedExitNode{name: "test", id: "test"},
},
{
name: "allowed suggested exit nodes not nil but length 0",
lastSuggestedExitNode: lastSuggestedExitNode{name: "test", id: "test"},
report: &netcheck.Report{
RegionLatency: map[int]time.Duration{
1: 10,
2: 10,
3: 5,
},
PreferredDERP: 1,
},
netMap: netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Addresses: []netip.Prefix{
netip.MustParsePrefix("100.64.1.1/32"),
netip.MustParsePrefix("fe70::1/128"),
},
}).View(),
DERPMap: &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {},
2: {},
3: {},
},
},
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
ID: 2,
StableID: "test",
Name: "test",
DERP: "127.3.3.40:1",
AllowedIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
},
CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{
tailcfg.NodeAttrSuggestExitNode: {},
tailcfg.NodeAttrAutoExitNode: {},
}),
}).View(),
(&tailcfg.Node{
ID: 3,
StableID: "foo",
Name: "foo",
DERP: "127.3.3.40:3",
AllowedIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
},
CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{
tailcfg.NodeAttrSuggestExitNode: {},
tailcfg.NodeAttrAutoExitNode: {},
}),
}).View(),
},
},
allowedSuggestedExitNodes: []string{},
wantID: "foo",
wantName: "foo",
wantLastSuggestedExitNode: lastSuggestedExitNode{name: "foo", id: "foo"},
},
}
for _, tt := range tests {
lb := newTestLocalBackend(t)
msh := &mockSyspolicyHandler{
t: t,
stringArrayPolicies: map[syspolicy.Key][]string{
syspolicy.AllowedSuggestedExitNodes: nil,
},
}
if len(tt.allowedSuggestedExitNodes) != 0 {
msh.stringArrayPolicies[syspolicy.AllowedSuggestedExitNodes] = tt.allowedSuggestedExitNodes
}
syspolicy.SetHandlerForTest(t, msh)
lb.lastSuggestedExitNode = tt.lastSuggestedExitNode
lb.netMap = &tt.netMap
lb.sys.MagicSock.Get().SetLastNetcheckReportForTest(context.Background(), tt.report)

View File

@@ -18,6 +18,7 @@ import (
"net/netip"
"os"
"path/filepath"
"slices"
"time"
"tailscale.com/health/healthmsg"
@@ -27,10 +28,12 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/tkatype"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
// TODO(tom): RPC retry/backoff was broken and has been removed. Fix?
@@ -66,6 +69,7 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
return // TKA not enabled.
}
tracker := rotationTracker{logf: b.logf}
var toDelete map[int]bool // peer index => true
for i, p := range nm.Peers {
if p.UnsignedPeerAPIOnly() {
@@ -76,21 +80,32 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
b.logf("Network lock is dropping peer %v(%v) due to missing signature", p.ID(), p.StableID())
mak.Set(&toDelete, i, true)
} else {
if err := b.tka.authority.NodeKeyAuthorized(p.Key(), p.KeySignature().AsSlice()); err != nil {
details, err := b.tka.authority.NodeKeyAuthorizedWithDetails(p.Key(), p.KeySignature().AsSlice())
if err != nil {
b.logf("Network lock is dropping peer %v(%v) due to failed signature check: %v", p.ID(), p.StableID(), err)
mak.Set(&toDelete, i, true)
continue
}
if details != nil {
// Rotation details are returned when the node key is signed by a valid SigRotation signature.
tracker.addRotationDetails(p.Key(), details)
}
}
}
obsoleteByRotation := tracker.obsoleteKeys()
// nm.Peers is ordered, so deletion must be order-preserving.
if len(toDelete) > 0 {
if len(toDelete) > 0 || len(obsoleteByRotation) > 0 {
peers := make([]tailcfg.NodeView, 0, len(nm.Peers))
filtered := make([]ipnstate.TKAFilteredPeer, 0, len(toDelete))
filtered := make([]ipnstate.TKAFilteredPeer, 0, len(toDelete)+len(obsoleteByRotation))
for i, p := range nm.Peers {
if !toDelete[i] {
if !toDelete[i] && !obsoleteByRotation.Contains(p.Key()) {
peers = append(peers, p)
} else {
if obsoleteByRotation.Contains(p.Key()) {
b.logf("Network lock is dropping peer %v(%v) due to key rotation", p.ID(), p.StableID())
}
// Record information about the node we filtered out.
fp := ipnstate.TKAFilteredPeer{
Name: p.Name(),
@@ -122,6 +137,84 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
}
}
// rotationTracker determines the set of node keys that are made obsolete by key
// rotation.
// - for each SigRotation signature, all previous node keys referenced by the
// nested signatures are marked as obsolete.
// - if there are multiple SigRotation signatures tracing back to the same
// wrapping pubkey (e.g. if a node is cloned with all its keys), we keep
// just one of them, marking the others as obsolete.
type rotationTracker struct {
// obsolete is the set of node keys that are obsolete due to key rotation.
// users of rotationTracker should use the obsoleteKeys method for complete results.
obsolete set.Set[key.NodePublic]
// byWrappingKey keeps track of rotation details per wrapping pubkey.
byWrappingKey map[string][]sigRotationDetails
logf logger.Logf
}
// sigRotationDetails holds information about a node key signed by a SigRotation.
type sigRotationDetails struct {
np key.NodePublic
numPrevKeys int
}
// addRotationDetails records the rotation signature details for a node key.
func (r *rotationTracker) addRotationDetails(np key.NodePublic, d *tka.RotationDetails) {
r.obsolete.Make()
r.obsolete.AddSlice(d.PrevNodeKeys)
rd := sigRotationDetails{
np: np,
numPrevKeys: len(d.PrevNodeKeys),
}
if r.byWrappingKey == nil {
r.byWrappingKey = make(map[string][]sigRotationDetails)
}
wp := string(d.WrappingPubkey)
r.byWrappingKey[wp] = append(r.byWrappingKey[wp], rd)
}
// obsoleteKeys returns the set of node keys that are obsolete due to key rotation.
func (r *rotationTracker) obsoleteKeys() set.Set[key.NodePublic] {
for _, v := range r.byWrappingKey {
// If there are multiple rotation signatures with the same wrapping
// pubkey, we need to decide which one is the "latest", and keep it.
// The signature with the largest number of previous keys is likely to
// be the latest, unless it has been marked as obsolete (rotated out) by
// another signature (which might happen in the future if we start
// compacting long rotated signature chains).
slices.SortStableFunc(v, func(a, b sigRotationDetails) int {
// Group all obsolete keys after non-obsolete keys.
if ao, bo := r.obsolete.Contains(a.np), r.obsolete.Contains(b.np); ao != bo {
if ao {
return 1
}
return -1
}
// Sort by decreasing number of previous keys.
return b.numPrevKeys - a.numPrevKeys
})
// If there are several signatures with the same number of previous
// keys, we cannot determine which one is the latest, so all of them are
// rejected for safety.
if len(v) >= 2 && v[0].numPrevKeys == v[1].numPrevKeys {
r.logf("at least two nodes (%s and %s) have equally valid rotation signatures with the same wrapping pubkey, rejecting", v[0].np, v[1].np)
for _, rd := range v {
r.obsolete.Add(rd.np)
}
} else {
// The first key in v is the one with the longest chain of previous
// keys, so it must be the newest one. Mark all older keys as obsolete.
for _, rd := range v[1:] {
r.obsolete.Add(rd.np)
}
}
}
return r.obsolete
}
// tkaSyncIfNeeded examines TKA info reported from the control plane,
// performing the steps necessary to synchronize local tka state.
//
@@ -423,8 +516,12 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
copy(head[:], h[:])
var selfAuthorized bool
nodeKeySignature := &tka.NodeKeySignature{}
if b.netMap != nil {
selfAuthorized = b.tka.authority.NodeKeyAuthorized(b.netMap.SelfNode.Key(), b.netMap.SelfNode.KeySignature().AsSlice()) == nil
if err := nodeKeySignature.Unserialize(b.netMap.SelfNode.KeySignature().AsSlice()); err != nil {
b.logf("failed to decode self node key signature: %v", err)
}
}
keys := b.tka.authority.Keys()
@@ -445,14 +542,15 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
stateID1, _ := b.tka.authority.StateIDs()
return &ipnstate.NetworkLockStatus{
Enabled: true,
Head: &head,
PublicKey: nlPriv.Public(),
NodeKey: nodeKey,
NodeKeySigned: selfAuthorized,
TrustedKeys: outKeys,
FilteredPeers: filtered,
StateID: stateID1,
Enabled: true,
Head: &head,
PublicKey: nlPriv.Public(),
NodeKey: nodeKey,
NodeKeySigned: selfAuthorized,
NodeKeySignature: nodeKeySignature,
TrustedKeys: outKeys,
FilteredPeers: filtered,
StateID: stateID1,
}
}

View File

@@ -13,8 +13,11 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
go4mem "go4.org/mem"
"github.com/google/go-cmp/cmp"
"tailscale.com/control/controlclient"
"tailscale.com/health"
@@ -30,6 +33,7 @@ import (
"tailscale.com/types/persist"
"tailscale.com/types/tkatype"
"tailscale.com/util/must"
"tailscale.com/util/set"
)
type observerFunc func(controlclient.Status)
@@ -563,18 +567,32 @@ func TestTKAFilterNetmap(t *testing.T) {
}
n4Sig.Signature[3] = 42 // mess up the signature
n4Sig.Signature[4] = 42 // mess up the signature
n5GoodSig, err := signNodeKey(tailcfg.TKASignInfo{NodePublic: n5.Public()}, nlPriv)
n5nl := key.NewNLPrivate()
n5InitialSig, err := signNodeKey(tailcfg.TKASignInfo{NodePublic: n5.Public(), RotationPubkey: n5nl.Public().Verifier()}, nlPriv)
if err != nil {
t.Fatal(err)
}
resign := func(nl key.NLPrivate, currentSig tkatype.MarshaledSignature) (key.NodePrivate, tkatype.MarshaledSignature) {
nk := key.NewNode()
sig, err := tka.ResignNKS(nl, nk.Public(), currentSig)
if err != nil {
t.Fatal(err)
}
return nk, sig
}
n5Rotated, n5RotatedSig := resign(n5nl, n5InitialSig.Serialize())
nm := &netmap.NetworkMap{
Peers: nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 2, Key: n2.Public(), KeySignature: nil}, // missing sig
{ID: 3, Key: n3.Public(), KeySignature: n1GoodSig.Serialize()}, // someone elses sig
{ID: 4, Key: n4.Public(), KeySignature: n4Sig.Serialize()}, // messed-up signature
{ID: 5, Key: n5.Public(), KeySignature: n5GoodSig.Serialize()},
{ID: 2, Key: n2.Public(), KeySignature: nil}, // missing sig
{ID: 3, Key: n3.Public(), KeySignature: n1GoodSig.Serialize()}, // someone elses sig
{ID: 4, Key: n4.Public(), KeySignature: n4Sig.Serialize()}, // messed-up signature
{ID: 50, Key: n5.Public(), KeySignature: n5InitialSig.Serialize()}, // rotated
{ID: 51, Key: n5Rotated.Public(), KeySignature: n5RotatedSig},
}),
}
@@ -586,12 +604,39 @@ func TestTKAFilterNetmap(t *testing.T) {
want := nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 5, Key: n5.Public(), KeySignature: n5GoodSig.Serialize()},
{ID: 51, Key: n5Rotated.Public(), KeySignature: n5RotatedSig},
})
nodePubComparer := cmp.Comparer(func(x, y key.NodePublic) bool {
return x.Raw32() == y.Raw32()
})
if diff := cmp.Diff(nm.Peers, want, nodePubComparer); diff != "" {
if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" {
t.Errorf("filtered netmap differs (-want, +got):\n%s", diff)
}
// Create two more node signatures using the same wrapping key as n5.
// Since they have the same rotation chain, both will be filtered out.
n7, n7Sig := resign(n5nl, n5RotatedSig)
n8, n8Sig := resign(n5nl, n5RotatedSig)
nm = &netmap.NetworkMap{
Peers: nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 2, Key: n2.Public(), KeySignature: nil}, // missing sig
{ID: 3, Key: n3.Public(), KeySignature: n1GoodSig.Serialize()}, // someone elses sig
{ID: 4, Key: n4.Public(), KeySignature: n4Sig.Serialize()}, // messed-up signature
{ID: 50, Key: n5.Public(), KeySignature: n5InitialSig.Serialize()}, // rotated
{ID: 51, Key: n5Rotated.Public(), KeySignature: n5RotatedSig}, // rotated
{ID: 7, Key: n7.Public(), KeySignature: n7Sig}, // same rotation chain as n8
{ID: 8, Key: n8.Public(), KeySignature: n8Sig}, // same rotation chain as n7
}),
}
b.tkaFilterNetmapLocked(nm)
want = nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
})
if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" {
t.Errorf("filtered netmap differs (-want, +got):\n%s", diff)
}
}
@@ -1130,3 +1175,85 @@ func TestTKARecoverCompromisedKeyFlow(t *testing.T) {
t.Errorf("NetworkLockSubmitRecoveryAUM() failed: %v", err)
}
}
func TestRotationTracker(t *testing.T) {
newNK := func(idx byte) key.NodePublic {
// single-byte public key to make it human-readable in tests.
raw32 := [32]byte{idx}
return key.NodePublicFromRaw32(go4mem.B(raw32[:]))
}
n1, n2, n3, n4, n5 := newNK(1), newNK(2), newNK(3), newNK(4), newNK(5)
pk1, pk2, pk3 := []byte{1}, []byte{2}, []byte{3}
type addDetails struct {
np key.NodePublic
details *tka.RotationDetails
}
tests := []struct {
name string
addDetails []addDetails
want set.Set[key.NodePublic]
}{
{
name: "empty",
want: nil,
},
{
name: "single_prev_key",
addDetails: []addDetails{
{np: n1, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n2}, WrappingPubkey: pk1}},
},
want: set.SetOf([]key.NodePublic{n2}),
},
{
name: "several_prev_keys",
addDetails: []addDetails{
{np: n1, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n2}, WrappingPubkey: pk1}},
{np: n3, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n4}, WrappingPubkey: pk2}},
{np: n2, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n3, n4}, WrappingPubkey: pk1}},
},
want: set.SetOf([]key.NodePublic{n2, n3, n4}),
},
{
name: "several_per_pubkey_latest_wins",
addDetails: []addDetails{
{np: n2, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1}, WrappingPubkey: pk3}},
{np: n3, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1, n2}, WrappingPubkey: pk3}},
{np: n4, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1, n2, n3}, WrappingPubkey: pk3}},
{np: n5, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n4}, WrappingPubkey: pk3}},
},
want: set.SetOf([]key.NodePublic{n1, n2, n3, n4}),
},
{
name: "several_per_pubkey_same_chain_length_all_rejected",
addDetails: []addDetails{
{np: n2, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1}, WrappingPubkey: pk3}},
{np: n3, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1, n2}, WrappingPubkey: pk3}},
{np: n4, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1, n2}, WrappingPubkey: pk3}},
{np: n5, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1, n2}, WrappingPubkey: pk3}},
},
want: set.SetOf([]key.NodePublic{n1, n2, n3, n4, n5}),
},
{
name: "several_per_pubkey_longest_wins",
addDetails: []addDetails{
{np: n2, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1}, WrappingPubkey: pk3}},
{np: n3, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1, n2}, WrappingPubkey: pk3}},
{np: n4, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1, n2}, WrappingPubkey: pk3}},
{np: n5, details: &tka.RotationDetails{PrevNodeKeys: []key.NodePublic{n1, n2, n3}, WrappingPubkey: pk3}},
},
want: set.SetOf([]key.NodePublic{n1, n2, n3, n4}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &rotationTracker{logf: t.Logf}
for _, ad := range tt.addDetails {
r.addRotationDetails(ad.np, ad.details)
}
if got := r.obsoleteKeys(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("rotationTracker.obsoleteKeys() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -354,10 +354,6 @@ func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error
return ipn.PrefsView{}, err
}
savedPrefs := ipn.NewPrefs()
// NewPrefs sets a default NoStatefulFiltering, but we want to actually see
// if the saved state had an empty value. The empty value gets migrated
// based on NoSNAT, while a default "false" does not.
savedPrefs.NoStatefulFiltering = ""
if err := ipn.PrefsFromBytes(bs, savedPrefs); err != nil {
return ipn.PrefsView{}, fmt.Errorf("parsing saved prefs: %v", err)
}
@@ -382,32 +378,6 @@ func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error
savedPrefs.AutoUpdate.Apply.Clear()
}
// Backfill a missing NoStatefulFiltering field based on the value of
// the NoSNAT field; we want to apply stateful filtering in all cases
// *except* where the user has disabled SNAT.
//
// Only backfill if the user hasn't set a value for
// NoStatefulFiltering, however.
_, haveNoStateful := savedPrefs.NoStatefulFiltering.Get()
if !haveNoStateful {
if savedPrefs.NoSNAT {
pm.logf("backfilling NoStatefulFiltering field to true because NoSNAT is set")
// No SNAT: no stateful filtering
savedPrefs.NoStatefulFiltering.Set(true)
} else {
pm.logf("backfilling NoStatefulFiltering field to false because NoSNAT is not set")
// SNAT (default): apply stateful filtering
savedPrefs.NoStatefulFiltering.Set(false)
}
// Write back to the preferences store now that we've updated it.
if err := pm.writePrefsToStore(key, savedPrefs.View()); err != nil {
return ipn.PrefsView{}, err
}
}
return savedPrefs.View(), nil
}

View File

@@ -4,7 +4,6 @@
package ipnlocal
import (
"encoding/json"
"fmt"
"os/user"
"strconv"
@@ -13,14 +12,12 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/util/must"
)
@@ -604,89 +601,6 @@ func TestProfileManagementWindows(t *testing.T) {
}
}
func TestProfileBackfillStatefulFiltering(t *testing.T) {
envknob.Setenv("TS_DEBUG_PROFILES", "true")
tests := []struct {
noSNAT bool
noStateful opt.Bool
want bool
}{
// Default: NoSNAT is false, NoStatefulFiltering is false, so
// we want it to stay false.
{false, "false", false},
// NoSNAT being set to true and NoStatefulFiltering being false
// should result in NoStatefulFiltering still being false,
// since it was explicitly set.
{true, "false", false},
// If NoSNAT is false, and NoStatefulFiltering is unset, we
// backfill it to 'false'.
{false, "", false},
// If NoSNAT is true, and NoStatefulFiltering is unset, we
// backfill to 'true' to not break users of NoSNAT.
//
// In other words: if the user is not using SNAT, they almost
// certainly also don't want to use stateful filtering.
{true, "", true},
// However, if the user specifies both NoSNAT and stateful
// filtering, don't change that.
{true, "true", true},
{false, "true", true},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("noSNAT=%v,noStateful=%q", tt.noSNAT, tt.noStateful), func(t *testing.T) {
prefs := ipn.NewPrefs()
prefs.Persist = &persist.Persist{
NodeID: tailcfg.StableNodeID("node1"),
UserProfile: tailcfg.UserProfile{
ID: tailcfg.UserID(1),
LoginName: "user1@example.com",
},
}
prefs.NoSNAT = tt.noSNAT
prefs.NoStatefulFiltering = tt.noStateful
// Make enough of a state store to load the prefs.
const profileName = "profile1"
bn := must.Get(json.Marshal(map[string]any{
string(ipn.CurrentProfileStateKey): []byte(profileName),
string(ipn.KnownProfilesStateKey): must.Get(json.Marshal(map[ipn.ProfileID]*ipn.LoginProfile{
profileName: {
ID: "profile1-id",
Key: profileName,
},
})),
profileName: prefs.ToBytes(),
}))
store := new(mem.Store)
err := store.LoadFromJSON([]byte(bn))
if err != nil {
t.Fatal(err)
}
ht := new(health.Tracker)
pm, err := newProfileManagerWithGOOS(store, t.Logf, ht, "linux")
if err != nil {
t.Fatal(err)
}
// Get the current profile and verify that we backfilled our
// StatefulFiltering boolean.
pf := pm.CurrentPrefs()
if !pf.NoStatefulFiltering().EqualBool(tt.want) {
t.Fatalf("got NoStatefulFiltering=%q, want %v", pf.NoStatefulFiltering(), tt.want)
}
})
}
}
// TestDefaultPrefs tests that defaultPrefs is just NewPrefs with
// LoggedOut=true (the Prefs we use before connecting to control). We shouldn't
// be putting any defaulting there, and instead put all defaults in NewPrefs.

View File

@@ -198,8 +198,8 @@ func (cc *mockControl) Shutdown() {
// Login starts a login process. Note that in this mock, we don't automatically
// generate notifications about the progress of the login operation. You have to
// call send() as required by the test.
func (cc *mockControl) Login(t *tailcfg.Oauth2Token, flags controlclient.LoginFlags) {
cc.logf("Login token=%v flags=%v", t, flags)
func (cc *mockControl) Login(flags controlclient.LoginFlags) {
cc.logf("Login flags=%v", flags)
cc.called("Login")
newKeys := cc.populateKeys()
@@ -265,7 +265,7 @@ func (b *LocalBackend) nonInteractiveLoginForStateTest() {
cc := b.cc
b.mu.Unlock()
cc.Login(nil, b.loginFlags|controlclient.LoginInteractive)
cc.Login(b.loginFlags | controlclient.LoginInteractive)
}
// A very precise test of the sequence of function calls generated by

View File

@@ -18,6 +18,7 @@ import (
"time"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/ptr"
"tailscale.com/types/views"
@@ -126,6 +127,9 @@ type NetworkLockStatus struct {
// NodeKeySigned is true if our node is authorized by network-lock.
NodeKeySigned bool
// NodeKeySignature is the current signature of this node's key.
NodeKeySignature *tka.NodeKeySignature
// TrustedKeys describes the keys currently trusted to make changes
// to network-lock.
TrustedKeys []TKAKey

View File

@@ -6,6 +6,7 @@ package localapi
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/hex"
@@ -1939,8 +1940,10 @@ func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) {
return
}
network := cmp.Or(r.Header.Get("Dial-Network"), "tcp")
addr := net.JoinHostPort(hostStr, portStr)
outConn, err := h.b.Dialer().UserDial(r.Context(), "tcp", addr)
outConn, err := h.b.Dialer().UserDial(r.Context(), network, addr)
if err != nil {
http.Error(w, "dial failure: "+err.Error(), http.StatusBadGateway)
return

View File

@@ -75,18 +75,6 @@ type Prefs struct {
// controlled by ExitNodeID/IP below.
RouteAll bool
// AllowSingleHosts specifies whether to install routes for each
// node IP on the tailscale network, in addition to a route for
// the whole network.
// This corresponds to the "tailscale up --host-routes" value,
// which defaults to true.
//
// TODO(danderson): why do we have this? It dumps a lot of stuff
// into the routing table, and a single network route _should_ be
// all that we need. But when I turn this off in my tailscaled,
// packets stop flowing. What's up with that?
AllowSingleHosts bool
// ExitNodeID and ExitNodeIP specify the node that should be used
// as an exit node for internet traffic. At most one of these
// should be non-zero.
@@ -203,17 +191,16 @@ type Prefs struct {
// Linux-only.
NoSNAT bool
// NoStatefulFiltering specifies whether to apply stateful filtering
// when advertising routes in AdvertiseRoutes. The default is to apply
// NoStatefulFiltering specifies whether to apply stateful filtering when
// advertising routes in AdvertiseRoutes. The default is to not apply
// stateful filtering.
//
// To allow inbound connections from advertised routes, both NoSNAT and
// NoStatefulFiltering must be true.
//
// This is an opt.Bool because it was added after NoSNAT, but is backfilled
// based on the value of that parameter. We need to treat it as a tristate:
// true, false, or unset, and backfill based on that value. See
// ipn/ipnlocal for more details on the backfill.
// This is an opt.Bool because it was first added after NoSNAT, with a
// backfill based on the value of that parameter. The backfill has been
// removed since then, but the field remains an opt.Bool.
//
// Linux-only.
NoStatefulFiltering opt.Bool `json:",omitempty"`
@@ -252,6 +239,16 @@ type Prefs struct {
// by name.
DriveShares []*drive.Share
// AllowSingleHosts was a legacy field that was always true
// for the past 4.5 years. It controlled whether Tailscale
// peers got /32 or /127 routes for each other.
// As of 2024-05-17 we're starting to ignore it, but to let
// people still downgrade Tailscale versions and not break
// all peer-to-peer networking we still write it to disk (as JSON)
// so it can be loaded back by old versions.
// TODO(bradfitz): delete this in 2025 sometime. See #12058.
AllowSingleHosts marshalAsTrueInJSON
// The Persist field is named 'Config' in the file for backward
// compatibility with earlier versions.
// TODO(apenwarr): We should move this out of here, it's not a pref.
@@ -282,6 +279,13 @@ func (au1 AutoUpdatePrefs) Equals(au2 AutoUpdatePrefs) bool {
ok1 == ok2
}
type marshalAsTrueInJSON struct{}
var trueJSON = []byte("true")
func (marshalAsTrueInJSON) MarshalJSON() ([]byte, error) { return trueJSON, nil }
func (*marshalAsTrueInJSON) UnmarshalJSON([]byte) error { return nil }
// AppConnectorPrefs are the app connector settings for the node agent.
type AppConnectorPrefs struct {
// Advertise specifies whether the app connector subsystem is advertising
@@ -299,7 +303,6 @@ type MaskedPrefs struct {
ControlURLSet bool `json:",omitempty"`
RouteAllSet bool `json:",omitempty"`
AllowSingleHostsSet bool `json:",omitempty"`
ExitNodeIDSet bool `json:",omitempty"`
ExitNodeIPSet bool `json:",omitempty"`
InternalExitNodePriorSet bool `json:",omitempty"` // Internal; can't be set by LocalAPI clients
@@ -484,9 +487,6 @@ func (p *Prefs) pretty(goos string) string {
var sb strings.Builder
sb.WriteString("Prefs{")
fmt.Fprintf(&sb, "ra=%v ", p.RouteAll)
if !p.AllowSingleHosts {
sb.WriteString("mesh=false ")
}
fmt.Fprintf(&sb, "dns=%v want=%v ", p.CorpDNS, p.WantRunning)
if p.RunSSH {
sb.WriteString("ssh=true ")
@@ -579,7 +579,6 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
return p.ControlURL == p2.ControlURL &&
p.RouteAll == p2.RouteAll &&
p.AllowSingleHosts == p2.AllowSingleHosts &&
p.ExitNodeID == p2.ExitNodeID &&
p.ExitNodeIP == p2.ExitNodeIP &&
p.InternalExitNodePrior == p2.InternalExitNodePrior &&
@@ -663,11 +662,10 @@ func NewPrefs() *Prefs {
ControlURL: "",
RouteAll: true,
AllowSingleHosts: true,
CorpDNS: true,
WantRunning: false,
NetfilterMode: preftype.NetfilterOn,
NoStatefulFiltering: opt.NewBool(false),
NoStatefulFiltering: opt.NewBool(true),
AutoUpdate: AutoUpdatePrefs{
Check: true,
Apply: opt.Bool("unset"),

View File

@@ -38,7 +38,6 @@ func TestPrefsEqual(t *testing.T) {
prefsHandles := []string{
"ControlURL",
"RouteAll",
"AllowSingleHosts",
"ExitNodeID",
"ExitNodeIP",
"InternalExitNodePrior",
@@ -65,6 +64,7 @@ func TestPrefsEqual(t *testing.T) {
"PostureChecking",
"NetfilterKind",
"DriveShares",
"AllowSingleHosts",
"Persist",
}
if have := fieldsOf(reflect.TypeFor[Prefs]()); !reflect.DeepEqual(have, prefsHandles) {
@@ -123,18 +123,6 @@ func TestPrefsEqual(t *testing.T) {
&Prefs{RouteAll: true},
true,
},
{
&Prefs{AllowSingleHosts: true},
&Prefs{AllowSingleHosts: false},
false,
},
{
&Prefs{AllowSingleHosts: true},
&Prefs{AllowSingleHosts: true},
true,
},
{
&Prefs{ExitNodeID: "n1234"},
&Prefs{},
@@ -376,7 +364,7 @@ func checkPrefs(t *testing.T, p Prefs) {
p2b = new(Prefs)
err = PrefsFromBytes(p2.ToBytes(), p2b)
if err != nil {
t.Fatalf("PrefsFromBytes(p2) failed\n")
t.Fatalf("PrefsFromBytes(p2) failed: bytes=%q; err=%v\n", p2.ToBytes(), err)
}
p2p := p2.Pretty()
p2bp := p2b.Pretty()
@@ -427,46 +415,43 @@ func TestPrefsPretty(t *testing.T) {
{
Prefs{},
"linux",
"Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}",
"Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}",
},
{
Prefs{},
"windows",
"Prefs{ra=false mesh=false dns=false want=false update=off Persist=nil}",
"Prefs{ra=false dns=false want=false update=off Persist=nil}",
},
{
Prefs{ShieldsUp: true},
"windows",
"Prefs{ra=false mesh=false dns=false want=false shields=true update=off Persist=nil}",
"Prefs{ra=false dns=false want=false shields=true update=off Persist=nil}",
},
{
Prefs{AllowSingleHosts: true},
Prefs{},
"windows",
"Prefs{ra=false dns=false want=false update=off Persist=nil}",
},
{
Prefs{
NotepadURLs: true,
AllowSingleHosts: true,
NotepadURLs: true,
},
"windows",
"Prefs{ra=false dns=false want=false notepad=true update=off Persist=nil}",
},
{
Prefs{
AllowSingleHosts: true,
WantRunning: true,
ForceDaemon: true, // server mode
WantRunning: true,
ForceDaemon: true, // server mode
},
"windows",
"Prefs{ra=false dns=false want=true server=true update=off Persist=nil}",
},
{
Prefs{
AllowSingleHosts: true,
WantRunning: true,
ControlURL: "http://localhost:1234",
AdvertiseTags: []string{"tag:foo", "tag:bar"},
WantRunning: true,
ControlURL: "http://localhost:1234",
AdvertiseTags: []string{"tag:foo", "tag:bar"},
},
"darwin",
`Prefs{ra=false dns=false want=true tags=tag:foo,tag:bar url="http://localhost:1234" update=off Persist=nil}`,
@@ -476,7 +461,7 @@ func TestPrefsPretty(t *testing.T) {
Persist: &persist.Persist{},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n= u=""}}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n= u=""}}`,
},
{
Prefs{
@@ -485,21 +470,21 @@ func TestPrefsPretty(t *testing.T) {
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n=[B1VKl] u=""}}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n=[B1VKl] u=""}}`,
},
{
Prefs{
ExitNodeIP: netip.MustParseAddr("1.2.3.4"),
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off update=off Persist=nil}`,
`Prefs{ra=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
ExitNodeID: tailcfg.StableNodeID("myNodeABC"),
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off update=off Persist=nil}`,
`Prefs{ra=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
@@ -507,21 +492,21 @@ func TestPrefsPretty(t *testing.T) {
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off update=off Persist=nil}`,
`Prefs{ra=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
Hostname: "foo",
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off host="foo" update=off Persist=nil}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off host="foo" update=off Persist=nil}`,
},
{
Prefs{
@@ -531,7 +516,7 @@ func TestPrefsPretty(t *testing.T) {
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=check Persist=nil}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off update=check Persist=nil}`,
},
{
Prefs{
@@ -541,7 +526,7 @@ func TestPrefsPretty(t *testing.T) {
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=on Persist=nil}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off update=on Persist=nil}`,
},
{
Prefs{
@@ -550,7 +535,7 @@ func TestPrefsPretty(t *testing.T) {
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off appconnector=advertise Persist=nil}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off appconnector=advertise Persist=nil}`,
},
{
Prefs{
@@ -559,21 +544,21 @@ func TestPrefsPretty(t *testing.T) {
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
NetfilterKind: "iptables",
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off netfilterKind=iptables update=off Persist=nil}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off netfilterKind=iptables update=off Persist=nil}`,
},
{
Prefs{
NetfilterKind: "",
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
},
}
for i, tt := range tests {
@@ -633,8 +618,9 @@ func TestMaskedPrefsSetsInternal(t *testing.T) {
func TestMaskedPrefsFields(t *testing.T) {
have := map[string]bool{}
for _, f := range fieldsOf(reflect.TypeFor[Prefs]()) {
if f == "Persist" {
// This one can't be edited.
switch f {
case "Persist", "AllowSingleHosts":
// These can't be edited.
continue
}
have[f] = true
@@ -753,13 +739,12 @@ func TestMaskedPrefsPretty(t *testing.T) {
{
m: &MaskedPrefs{
Prefs: Prefs{
Hostname: "bar",
OperatorUser: "galaxybrain",
AllowSingleHosts: true,
RouteAll: false,
ExitNodeID: "foo",
AdvertiseTags: []string{"tag:foo", "tag:bar"},
NetfilterMode: preftype.NetfilterNoDivert,
Hostname: "bar",
OperatorUser: "galaxybrain",
RouteAll: false,
ExitNodeID: "foo",
AdvertiseTags: []string{"tag:foo", "tag:bar"},
NetfilterMode: preftype.NetfilterNoDivert,
},
RouteAllSet: true,
HostnameSet: true,
@@ -1064,3 +1049,24 @@ func TestNotifyPrefsJSONRoundtrip(t *testing.T) {
t.Fatal("Prefs should not be valid after deserialization")
}
}
// Verify that our Prefs type writes out an AllowSingleHosts field so we can
// downgrade to older versions that require it.
func TestPrefsDowngrade(t *testing.T) {
var p Prefs
j, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
type oldPrefs struct {
AllowSingleHosts bool
}
var op oldPrefs
if err := json.Unmarshal(j, &op); err != nil {
t.Fatal(err)
}
if !op.AllowSingleHosts {
t.Fatal("AllowSingleHosts should be true")
}
}

View File

@@ -626,7 +626,7 @@ func (v ServeConfigView) HasAllowFunnel() bool {
}()
}
// FindFunnel reports whether target exists in in either the background AllowFunnel
// FindFunnel reports whether target exists in either the background AllowFunnel
// or any of the foreground configs.
func (v ServeConfigView) HasFunnelForTarget(target HostPort) bool {
if v.AllowFunnel().Get(target) {

View File

@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
@@ -30,6 +31,10 @@ func New(_ logger.Logf, secretName string) (*Store, error) {
if err != nil {
return nil, err
}
if os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
// Derive the API server address from the environment variables
c.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
}
canPatch, _, err := c.CheckSecretPermissions(context.Background(), secretName)
if err != nil {
return nil, err

View File

@@ -35,7 +35,7 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/5e242ec57806/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.5.0/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE))
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.2.0/LICENSE))
- [github.com/illarion/gonotify](https://pkg.go.dev/github.com/illarion/gonotify) ([MIT](https://github.com/illarion/gonotify/blob/v1.0.1/LICENSE))
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/8c70d406f6d2/LICENSE))

View File

@@ -58,6 +58,7 @@ See also the dependencies in the [Tailscale CLI][].
- [github.com/miekg/dns](https://pkg.go.dev/github.com/miekg/dns) ([BSD-3-Clause](https://github.com/miekg/dns/blob/v1.1.58/LICENSE))
- [github.com/mitchellh/go-ps](https://pkg.go.dev/github.com/mitchellh/go-ps) ([MIT](https://github.com/mitchellh/go-ps/blob/v1.0.0/LICENSE.md))
- [github.com/pierrec/lz4/v4](https://pkg.go.dev/github.com/pierrec/lz4/v4) ([BSD-3-Clause](https://github.com/pierrec/lz4/blob/v4.1.21/LICENSE))
- [github.com/prometheus-community/pro-bing](https://pkg.go.dev/github.com/prometheus-community/pro-bing) ([MIT](https://github.com/prometheus-community/pro-bing/blob/v0.4.0/LICENSE))
- [github.com/safchain/ethtool](https://pkg.go.dev/github.com/safchain/ethtool) ([Apache-2.0](https://github.com/safchain/ethtool/blob/v0.3.0/LICENSE))
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/7ce1f622c780/LICENSE))
- [github.com/tailscale/goupnp](https://pkg.go.dev/github.com/tailscale/goupnp) ([BSD-2-Clause](https://github.com/tailscale/goupnp/blob/c64d0f06ea05/LICENSE))

View File

@@ -47,7 +47,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/5e242ec57806/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.5.0/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE))
- [github.com/gorilla/csrf](https://pkg.go.dev/github.com/gorilla/csrf) ([BSD-3-Clause](https://github.com/gorilla/csrf/blob/v1.7.2/LICENSE))
- [github.com/gorilla/securecookie](https://pkg.go.dev/github.com/gorilla/securecookie) ([BSD-3-Clause](https://github.com/gorilla/securecookie/blob/v1.1.2/LICENSE))
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.2.0/LICENSE))
@@ -73,6 +73,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/peterbourgon/ff/v3](https://pkg.go.dev/github.com/peterbourgon/ff/v3) ([Apache-2.0](https://github.com/peterbourgon/ff/blob/v3.4.0/LICENSE))
- [github.com/pierrec/lz4/v4](https://pkg.go.dev/github.com/pierrec/lz4/v4) ([BSD-3-Clause](https://github.com/pierrec/lz4/blob/v4.1.21/LICENSE))
- [github.com/pkg/sftp](https://pkg.go.dev/github.com/pkg/sftp) ([BSD-2-Clause](https://github.com/pkg/sftp/blob/v1.13.6/LICENSE))
- [github.com/prometheus-community/pro-bing](https://pkg.go.dev/github.com/prometheus-community/pro-bing) ([MIT](https://github.com/prometheus-community/pro-bing/blob/v0.4.0/LICENSE))
- [github.com/safchain/ethtool](https://pkg.go.dev/github.com/safchain/ethtool) ([Apache-2.0](https://github.com/safchain/ethtool/blob/v0.3.0/LICENSE))
- [github.com/skip2/go-qrcode](https://pkg.go.dev/github.com/skip2/go-qrcode) ([MIT](https://github.com/skip2/go-qrcode/blob/da1b6568686e/LICENSE))
- [github.com/tailscale/certstore](https://pkg.go.dev/github.com/tailscale/certstore) ([MIT](https://github.com/tailscale/certstore/blob/d3fa0460f47e/LICENSE.md))

View File

@@ -44,9 +44,8 @@ func NewBackoff(name string, logf logger.Logf, maxBackoff time.Duration) *Backof
}
}
// Backoff sleeps an increasing amount of time if err is non-nil.
// and the context is not a
// It resets the backoff schedule once err is nil.
// BackOff sleeps an increasing amount of time if err is non-nil while the
// context is active. It resets the backoff schedule once err is nil.
func (b *Backoff) BackOff(ctx context.Context, err error) {
if err == nil {
// No error. Reset number of consecutive failures.

View File

@@ -262,6 +262,18 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
// config is empty, then we need to fallback to SplitDNS mode.
ocfg.MatchDomains = cfg.matchDomains()
} else {
// On iOS only (for now), check if all route names point to resources inside the tailnet.
// If so, we can set those names as MatchDomains to enable a split DNS configuration
// which will help preserve battery life.
// Because on iOS MatchDomains must equal SearchDomains, we cannot do this when
// we have any Routes outside the tailnet. Otherwise when app connectors are enabled,
// a query for 'work-laptop' might lead to search domain expansion, resolving
// as 'work-laptop.aws.com' for example.
if runtime.GOOS == "ios" && rcfg.RoutesRequireNoCustomResolvers() {
for r := range rcfg.Routes {
ocfg.MatchDomains = append(ocfg.MatchDomains, r)
}
}
var defaultRoutes []*dnstype.Resolver
for _, ip := range baseCfg.Nameservers {
defaultRoutes = append(defaultRoutes, &dnstype.Resolver{Addr: ip.String()})

View File

@@ -125,8 +125,8 @@ func DoHIPsOfBase(dohBase string) []netip.Addr {
return []netip.Addr{
controlDv4One,
controlDv4Two,
controlDv6Gen(nextDNSv6RangeA.Addr(), pathStr),
controlDv6Gen(nextDNSv6RangeB.Addr(), pathStr),
controlDv6Gen(controlDv6RangeA.Addr(), pathStr),
controlDv6Gen(controlDv6RangeB.Addr(), pathStr),
}
}
return nil

View File

@@ -121,8 +121,8 @@ func TestDoHIPsOfBase(t *testing.T) {
want: ips(
"76.76.2.22",
"76.76.10.22",
"2a07:a8c0:0:6:7b5b:5949:35ad:0",
"2a07:a8c1:0:6:7b5b:5949:35ad:0",
"2606:1a40:0:6:7b5b:5949:35ad:0",
"2606:1a40:1:6:7b5b:5949:35ad:0",
),
},
{
@@ -130,8 +130,8 @@ func TestDoHIPsOfBase(t *testing.T) {
want: ips(
"76.76.2.22",
"76.76.10.22",
"2a07:a8c0:0:ffff:ffff:ffff:ffff:0",
"2a07:a8c1:0:ffff:ffff:ffff:ffff:0",
"2606:1a40:0:ffff:ffff:ffff:ffff:0",
"2606:1a40:1:ffff:ffff:ffff:ffff:0",
),
},
}

View File

@@ -175,6 +175,25 @@ func WriteRoutes(w *bufio.Writer, routes map[dnsname.FQDN][]*dnstype.Resolver) {
}
}
// RoutesRequireNoCustomResolvers returns true if this resolver.Config only contains routes
// that do not specify a set of custom resolver(s), i.e. they can be resolved by the local
// upstream DNS resolver.
func (c *Config) RoutesRequireNoCustomResolvers() bool {
for route, resolvers := range c.Routes {
if route.WithoutTrailingDot() == "ts.net" {
// Ignore the "ts.net" route here. It always specifies the corp resolvers but
// its presence is not an issue, as ts.net will be a search domain.
continue
}
if len(resolvers) != 0 {
// Found a route with custom resolvers.
return false
}
}
// No routes other than ts.net have specified one or more resolvers.
return true
}
// Resolver is a DNS resolver for nodes on the Tailscale network,
// associating them with domain names of the form <mynode>.<mydomain>.<root>.
// If it is asked to resolve a domain that is not of that form,

View File

@@ -243,6 +243,43 @@ func mustIP(str string) netip.Addr {
return ip
}
func TestRoutesRequireNoCustomResolvers(t *testing.T) {
tests := []struct {
name string
config Config
expected bool
}{
{"noRoutes", Config{Routes: map[dnsname.FQDN][]*dnstype.Resolver{}}, true},
{"onlyDefault", Config{Routes: map[dnsname.FQDN][]*dnstype.Resolver{
"ts.net.": {
{},
},
}}, true},
{"oneOther", Config{Routes: map[dnsname.FQDN][]*dnstype.Resolver{
"example.com.": {
{},
},
}}, false},
{"defaultAndOneOther", Config{Routes: map[dnsname.FQDN][]*dnstype.Resolver{
"ts.net.": {
{},
},
"example.com.": {
{},
},
}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.RoutesRequireNoCustomResolvers()
if result != tt.expected {
t.Errorf("result = %v; want %v", result, tt.expected)
}
})
}
}
func TestRDNSNameToIPv4(t *testing.T) {
tests := []struct {
name string

View File

@@ -13,6 +13,7 @@ import (
"fmt"
"io"
"log"
"maps"
"math/rand"
"net"
"net/http"
@@ -63,9 +64,6 @@ const (
// icmpProbeTimeout is the maximum amount of time netcheck will spend
// probing with ICMP packets.
icmpProbeTimeout = 1 * time.Second
// hairpinCheckTimeout is the amount of time we wait for a
// hairpinned packet to come back.
hairpinCheckTimeout = 100 * time.Millisecond
// defaultActiveRetransmitTime is the retransmit interval we use
// for STUN probes when we're in steady state (not in start-up),
// but don't have previous latency information for a DERP
@@ -95,11 +93,6 @@ type Report struct {
// STUN server you're talking to (on IPv4).
MappingVariesByDestIP opt.Bool
// HairPinning is whether the router supports communicating
// between two local devices through the NATted public IP address
// (on IPv4).
HairPinning opt.Bool
// UPnP is whether UPnP appears present on the LAN.
// Empty means not checked.
UPnP opt.Bool
@@ -115,8 +108,11 @@ type Report struct {
RegionV4Latency map[int]time.Duration // keyed by DERP Region ID
RegionV6Latency map[int]time.Duration // keyed by DERP Region ID
GlobalV4 string // ip:port of global IPv4
GlobalV6 string // [ip]:port of global IPv6
GlobalV4Counters map[netip.AddrPort]int // number of times the endpoint was observed
GlobalV6Counters map[netip.AddrPort]int // number of times the endpoint was observed
GlobalV4 netip.AddrPort
GlobalV6 netip.AddrPort
// CaptivePortal is set when we think there's a captive portal that is
// intercepting HTTP traffic.
@@ -125,6 +121,43 @@ type Report struct {
// TODO: update Clone when adding new fields
}
// GetGlobalAddrs returns the v4 and v6 global addresses observed during the
// netcheck, which includes the best latency endpoint first, followed by any
// other endpoints that were observed repeatedly. It excludes singular endpoints
// that are likely only the result of a hard NAT.
func (r *Report) GetGlobalAddrs() (v4, v6 []netip.AddrPort) {
// Always add the best latency entries first.
if r.GlobalV4.IsValid() {
v4 = append(v4, r.GlobalV4)
}
if r.GlobalV6.IsValid() {
v6 = append(v6, r.GlobalV6)
}
// Add any other entries for which we have multiple observations.
// This covers a case of bad NATs that start to provide new mappings for new
// STUN sessions mid-expiration, even while a live mapping for the best
// latency endpoint still exists. This has been observed on some Palo Alto
// Networks firewalls, wherein new traffic to the old endpoint will not
// succeed, but new traffic to the newly discovered endpoints does succeed.
for ipp, count := range r.GlobalV4Counters {
if ipp == r.GlobalV4 {
continue
}
if count > 1 {
v4 = append(v4, ipp)
}
}
for ipp, count := range r.GlobalV6Counters {
if ipp == r.GlobalV6 {
continue
}
if count > 1 {
v6 = append(v6, ipp)
}
}
return v4, v6
}
// AnyPortMappingChecked reports whether any of UPnP, PMP, or PCP are non-empty.
func (r *Report) AnyPortMappingChecked() bool {
return r.UPnP != "" || r.PMP != "" || r.PCP != ""
@@ -138,6 +171,8 @@ func (r *Report) Clone() *Report {
r2.RegionLatency = cloneDurationMap(r2.RegionLatency)
r2.RegionV4Latency = cloneDurationMap(r2.RegionV4Latency)
r2.RegionV6Latency = cloneDurationMap(r2.RegionV6Latency)
r2.GlobalV4Counters = maps.Clone(r2.GlobalV4Counters)
r2.GlobalV6Counters = maps.Clone(r2.GlobalV6Counters)
return &r2
}
@@ -243,23 +278,6 @@ func (c *Client) vlogf(format string, a ...any) {
}
}
// handleHairSTUN reports whether pkt (from src) was our magic hairpin
// probe packet that we sent to ourselves.
func (c *Client) handleHairSTUNLocked(pkt []byte, src netip.AddrPort) bool {
rs := c.curState
if rs == nil {
return false
}
if tx, err := stun.ParseBindingRequest(pkt); err == nil && tx == rs.hairTX {
select {
case rs.gotHairSTUN <- src:
default:
}
return true
}
return false
}
// MakeNextReportFull forces the next GetReport call to be a full
// (non-incremental) probe of all DERP regions.
func (c *Client) MakeNextReportFull() {
@@ -282,10 +300,6 @@ func (c *Client) ReceiveSTUNPacket(pkt []byte, src netip.AddrPort) {
}
c.mu.Lock()
if c.handleHairSTUNLocked(pkt, src) {
c.mu.Unlock()
return
}
rs := c.curState
c.mu.Unlock()
@@ -296,6 +310,8 @@ func (c *Client) ReceiveSTUNPacket(pkt []byte, src netip.AddrPort) {
tx, addrPort, err := stun.ParseResponse(pkt)
if err != nil {
if _, err := stun.ParseBindingRequest(pkt); err == nil {
// We no longer send hairpin checks, but perhaps we might catch a
// stray from earlier versions.
// This was probably our own netcheck hairpin
// check probe coming in late. Ignore.
return
@@ -521,20 +537,15 @@ type reportState struct {
c *Client
start time.Time
opts *GetReportOpts
hairTX stun.TxID
gotHairSTUN chan netip.AddrPort
hairTimeout chan struct{} // closed on timeout
pc4Hair nettype.PacketConn
incremental bool // doing a lite, follow-up netcheck
stopProbeCh chan struct{}
waitPortMap sync.WaitGroup
mu sync.Mutex
sentHairCheck bool
report *Report // to be returned by GetReport
inFlight map[stun.TxID]func(netip.AddrPort) // called without c.mu held
gotEP4 string
timers []*time.Timer
mu sync.Mutex
report *Report // to be returned by GetReport
inFlight map[stun.TxID]func(netip.AddrPort) // called without c.mu held
gotEP4 netip.AddrPort
timers []*time.Timer
}
func (rs *reportState) anyUDP() bool {
@@ -584,50 +595,6 @@ func (rs *reportState) probeWouldHelp(probe probe, node *tailcfg.DERPNode) bool
return false
}
func (rs *reportState) startHairCheckLocked(dst netip.AddrPort) {
if rs.sentHairCheck || rs.incremental {
return
}
rs.sentHairCheck = true
rs.pc4Hair.WriteToUDPAddrPort(stun.Request(rs.hairTX), dst)
rs.c.vlogf("sent haircheck to %v", dst)
time.AfterFunc(hairpinCheckTimeout, func() { close(rs.hairTimeout) })
}
func (rs *reportState) waitHairCheck(ctx context.Context) {
rs.mu.Lock()
defer rs.mu.Unlock()
ret := rs.report
if rs.incremental {
if rs.c.last != nil {
ret.HairPinning = rs.c.last.HairPinning
}
return
}
if !rs.sentHairCheck {
return
}
// First, check whether we have a value before we check for timeouts.
select {
case <-rs.gotHairSTUN:
ret.HairPinning.Set(true)
return
default:
}
// Now, wait for a response or a timeout.
select {
case <-rs.gotHairSTUN:
ret.HairPinning.Set(true)
case <-rs.hairTimeout:
rs.c.vlogf("hairCheck timeout")
ret.HairPinning.Set(false)
case <-ctx.Done():
rs.c.vlogf("hairCheck context timeout")
}
}
func (rs *reportState) stopTimers() {
rs.mu.Lock()
defer rs.mu.Unlock()
@@ -640,11 +607,6 @@ func (rs *reportState) stopTimers() {
// is non-zero (for all but HTTPS replies), it's recorded as our UDP
// IP:port.
func (rs *reportState) addNodeLatency(node *tailcfg.DERPNode, ipp netip.AddrPort, d time.Duration) {
var ipPortStr string
if ipp != (netip.AddrPort{}) {
ipPortStr = net.JoinHostPort(ipp.Addr().String(), fmt.Sprint(ipp.Port()))
}
rs.mu.Lock()
defer rs.mu.Unlock()
ret := rs.report
@@ -670,18 +632,19 @@ func (rs *reportState) addNodeLatency(node *tailcfg.DERPNode, ipp netip.AddrPort
case ipp.Addr().Is6():
updateLatency(ret.RegionV6Latency, node.RegionID, d)
ret.IPv6 = true
ret.GlobalV6 = ipPortStr
ret.GlobalV6 = ipp
mak.Set(&ret.GlobalV6Counters, ipp, ret.GlobalV6Counters[ipp]+1)
// TODO: track MappingVariesByDestIP for IPv6
// too? Would be sad if so, but who knows.
case ipp.Addr().Is4():
updateLatency(ret.RegionV4Latency, node.RegionID, d)
ret.IPv4 = true
if rs.gotEP4 == "" {
rs.gotEP4 = ipPortStr
ret.GlobalV4 = ipPortStr
rs.startHairCheckLocked(ipp)
mak.Set(&ret.GlobalV4Counters, ipp, ret.GlobalV4Counters[ipp]+1)
if !rs.gotEP4.IsValid() {
rs.gotEP4 = ipp
ret.GlobalV4 = ipp
} else {
if rs.gotEP4 != ipPortStr {
if rs.gotEP4 != ipp {
ret.MappingVariesByDestIP.Set(true)
} else if ret.MappingVariesByDestIP == "" {
ret.MappingVariesByDestIP.Set(false)
@@ -793,9 +756,6 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
opts: opts,
report: newReport(),
inFlight: map[stun.TxID]func(netip.AddrPort){},
hairTX: stun.NewTxID(), // random payload
gotHairSTUN: make(chan netip.AddrPort, 1),
hairTimeout: make(chan struct{}),
stopProbeCh: make(chan struct{}, 1),
}
c.curState = rs
@@ -853,34 +813,11 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
v6udp.Close()
}
// Create a UDP4 socket used for sending to our discovered IPv4 address.
rs.pc4Hair, err = nettype.MakePacketListenerWithNetIP(netns.Listener(c.logf, c.NetMon)).ListenPacket(ctx, "udp4", ":0")
if err != nil {
c.logf("udp4: %v", err)
return nil, err
}
defer rs.pc4Hair.Close()
if !c.SkipExternalNetwork && c.PortMapper != nil {
rs.waitPortMap.Add(1)
go rs.probePortMapServices()
}
// At least the Apple Airport Extreme doesn't allow hairpin
// sends from a private socket until it's seen traffic from
// that src IP:port to something else out on the internet.
//
// See https://github.com/tailscale/tailscale/issues/188#issuecomment-600728643
//
// And it seems that even sending to a likely-filtered RFC 5737
// documentation-only IPv4 range is enough to set up the mapping.
// So do that for now. In the future we might want to classify networks
// that do and don't require this separately. But for now help it.
const documentationIP = "203.0.113.1"
rs.pc4Hair.WriteToUDPAddrPort(
[]byte("tailscale netcheck; see https://github.com/tailscale/tailscale/issues/188"),
netip.AddrPortFrom(netip.MustParseAddr(documentationIP), 12345))
plan := makeProbePlan(dm, ifState, last)
// If we're doing a full probe, also check for a captive portal. We
@@ -958,8 +895,6 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
captivePortalStop()
}
rs.waitHairCheck(ctx)
c.vlogf("hairCheck done")
if !c.SkipExternalNetwork && c.PortMapper != nil {
rs.waitPortMap.Wait()
c.vlogf("portMap done")
@@ -1328,17 +1263,16 @@ func (c *Client) logConciseReport(r *Report, dm *tailcfg.DERPMap) {
fmt.Fprintf(w, " v6os=%v", r.OSHasIPv6)
}
fmt.Fprintf(w, " mapvarydest=%v", r.MappingVariesByDestIP)
fmt.Fprintf(w, " hair=%v", r.HairPinning)
if r.AnyPortMappingChecked() {
fmt.Fprintf(w, " portmap=%v%v%v", conciseOptBool(r.UPnP, "U"), conciseOptBool(r.PMP, "M"), conciseOptBool(r.PCP, "C"))
} else {
fmt.Fprintf(w, " portmap=?")
}
if r.GlobalV4 != "" {
fmt.Fprintf(w, " v4a=%v", r.GlobalV4)
if r.GlobalV4.IsValid() {
fmt.Fprintf(w, " v4a=%s", r.GlobalV4)
}
if r.GlobalV6 != "" {
fmt.Fprintf(w, " v6a=%v", r.GlobalV6)
if r.GlobalV6.IsValid() {
fmt.Fprintf(w, " v6a=%s", r.GlobalV6)
}
if r.CaptivePortal != "" {
fmt.Fprintf(w, " captiveportal=%v", r.CaptivePortal)

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"net/netip"
"reflect"
"slices"
"sort"
"strconv"
"strings"
@@ -19,142 +20,12 @@ import (
"time"
"tailscale.com/net/netmon"
"tailscale.com/net/stun"
"tailscale.com/net/stun/stuntest"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstest/nettest"
)
func TestHairpinSTUN(t *testing.T) {
tx := stun.NewTxID()
c := &Client{
curState: &reportState{
hairTX: tx,
gotHairSTUN: make(chan netip.AddrPort, 1),
},
}
req := stun.Request(tx)
if !stun.Is(req) {
t.Fatal("expected STUN message")
}
if !c.handleHairSTUNLocked(req, netip.AddrPort{}) {
t.Fatal("expected true")
}
select {
case <-c.curState.gotHairSTUN:
default:
t.Fatal("expected value")
}
}
func TestHairpinWait(t *testing.T) {
makeClient := func(t *testing.T) (*Client, *reportState) {
tx := stun.NewTxID()
c := &Client{}
req := stun.Request(tx)
if !stun.Is(req) {
t.Fatal("expected STUN message")
}
var err error
rs := &reportState{
c: c,
hairTX: tx,
gotHairSTUN: make(chan netip.AddrPort, 1),
hairTimeout: make(chan struct{}),
report: newReport(),
}
rs.pc4Hair, err = net.ListenUDP("udp4", &net.UDPAddr{
IP: net.ParseIP("127.0.0.1"),
Port: 0,
})
if err != nil {
t.Fatal(err)
}
c.curState = rs
return c, rs
}
ll, err := net.ListenPacket("udp", "localhost:0")
if err != nil {
t.Fatal(err)
}
defer ll.Close()
dstAddr := netip.MustParseAddrPort(ll.LocalAddr().String())
t.Run("Success", func(t *testing.T) {
c, rs := makeClient(t)
req := stun.Request(rs.hairTX)
// Start a hairpin check to ourselves.
rs.startHairCheckLocked(dstAddr)
// Fake receiving the stun check from ourselves after some period of time.
src := netip.MustParseAddrPort(rs.pc4Hair.LocalAddr().String())
c.handleHairSTUNLocked(req, src)
rs.waitHairCheck(context.Background())
// Verify that we set HairPinning
if got := rs.report.HairPinning; !got.EqualBool(true) {
t.Errorf("wanted HairPinning=true, got %v", got)
}
})
t.Run("LateReply", func(t *testing.T) {
c, rs := makeClient(t)
req := stun.Request(rs.hairTX)
// Start a hairpin check to ourselves.
rs.startHairCheckLocked(dstAddr)
// Wait until we've timed out, to mimic the race in #1795.
<-rs.hairTimeout
// Fake receiving the stun check from ourselves after some period of time.
src := netip.MustParseAddrPort(rs.pc4Hair.LocalAddr().String())
c.handleHairSTUNLocked(req, src)
// Wait for a hairpin response
rs.waitHairCheck(context.Background())
// Verify that we set HairPinning
if got := rs.report.HairPinning; !got.EqualBool(true) {
t.Errorf("wanted HairPinning=true, got %v", got)
}
})
t.Run("Timeout", func(t *testing.T) {
_, rs := makeClient(t)
// Start a hairpin check to ourselves.
rs.startHairCheckLocked(dstAddr)
ctx, cancel := context.WithTimeout(context.Background(), hairpinCheckTimeout*50)
defer cancel()
// Wait in the background
waitDone := make(chan struct{})
go func() {
rs.waitHairCheck(ctx)
close(waitDone)
}()
// If we do nothing, then we time out; confirm that we set
// HairPinning to false in this case.
select {
case <-waitDone:
if got := rs.report.HairPinning; !got.EqualBool(false) {
t.Errorf("wanted HairPinning=false, got %v", got)
}
case <-ctx.Done():
t.Fatalf("timed out waiting for hairpin channel")
}
})
}
func newTestClient(t testing.TB) *Client {
c := &Client{
NetMon: netmon.NewStatic(),
@@ -189,12 +60,49 @@ func TestBasic(t *testing.T) {
if _, ok := r.RegionLatency[1]; !ok {
t.Errorf("expected key 1 in DERPLatency; got %+v", r.RegionLatency)
}
if r.GlobalV4 == "" {
if !r.GlobalV4.IsValid() {
t.Error("expected GlobalV4 set")
}
if r.PreferredDERP != 1 {
t.Errorf("PreferredDERP = %v; want 1", r.PreferredDERP)
}
v4Addrs, _ := r.GetGlobalAddrs()
if len(v4Addrs) != 1 {
t.Error("expected one global IPv4 address")
}
if got, want := v4Addrs[0], r.GlobalV4; got != want {
t.Errorf("got %v; want %v", got, want)
}
}
func TestMultiGlobalAddressMapping(t *testing.T) {
c := &Client{
Logf: t.Logf,
}
rs := &reportState{
c: c,
start: time.Now(),
report: newReport(),
}
derpNode := &tailcfg.DERPNode{}
port1 := netip.MustParseAddrPort("127.0.0.1:1234")
port2 := netip.MustParseAddrPort("127.0.0.1:2345")
port3 := netip.MustParseAddrPort("127.0.0.1:3456")
// First report for port1
rs.addNodeLatency(derpNode, port1, 10*time.Millisecond)
// Singular report for port2
rs.addNodeLatency(derpNode, port2, 11*time.Millisecond)
// Duplicate reports for port3
rs.addNodeLatency(derpNode, port3, 12*time.Millisecond)
rs.addNodeLatency(derpNode, port3, 13*time.Millisecond)
r := rs.report
v4Addrs, _ := r.GetGlobalAddrs()
wantV4Addrs := []netip.AddrPort{port1, port3}
if !slices.Equal(v4Addrs, wantV4Addrs) {
t.Errorf("got global addresses: %v, want %v", v4Addrs, wantV4Addrs)
}
}
func TestWorksWhenUDPBlocked(t *testing.T) {
@@ -745,12 +653,12 @@ func TestLogConciseReport(t *testing.T) {
{
name: "no_udp",
r: &Report{},
want: "udp=false v4=false icmpv4=false v6=false mapvarydest= hair= portmap=? derp=0",
want: "udp=false v4=false icmpv4=false v6=false mapvarydest= portmap=? derp=0",
},
{
name: "no_udp_icmp",
r: &Report{ICMPv4: true, IPv4: true},
want: "udp=false icmpv4=true v6=false mapvarydest= hair= portmap=? derp=0",
want: "udp=false icmpv4=true v6=false mapvarydest= portmap=? derp=0",
},
{
name: "ipv4_one_region",
@@ -765,7 +673,7 @@ func TestLogConciseReport(t *testing.T) {
1: 10 * ms,
},
},
want: "udp=true v6=false mapvarydest= hair= portmap=? derp=1 derpdist=1v4:10ms",
want: "udp=true v6=false mapvarydest= portmap=? derp=1 derpdist=1v4:10ms",
},
{
name: "ipv4_all_region",
@@ -784,7 +692,7 @@ func TestLogConciseReport(t *testing.T) {
3: 30 * ms,
},
},
want: "udp=true v6=false mapvarydest= hair= portmap=? derp=1 derpdist=1v4:10ms,2v4:20ms,3v4:30ms",
want: "udp=true v6=false mapvarydest= portmap=? derp=1 derpdist=1v4:10ms,2v4:20ms,3v4:30ms",
},
{
name: "ipboth_all_region",
@@ -809,7 +717,7 @@ func TestLogConciseReport(t *testing.T) {
3: 30 * ms,
},
},
want: "udp=true v6=true mapvarydest= hair= portmap=? derp=1 derpdist=1v4:10ms,1v6:10ms,2v4:20ms,2v6:20ms,3v4:30ms,3v6:30ms",
want: "udp=true v6=true mapvarydest= portmap=? derp=1 derpdist=1v4:10ms,1v6:10ms,2v4:20ms,2v6:20ms,3v4:30ms,3v6:30ms",
},
{
name: "portmap_all",
@@ -819,7 +727,7 @@ func TestLogConciseReport(t *testing.T) {
PMP: "true",
PCP: "true",
},
want: "udp=true v4=false v6=false mapvarydest= hair= portmap=UMC derp=0",
want: "udp=true v4=false v6=false mapvarydest= portmap=UMC derp=0",
},
{
name: "portmap_some",
@@ -829,7 +737,7 @@ func TestLogConciseReport(t *testing.T) {
PMP: "false",
PCP: "true",
},
want: "udp=true v4=false v6=false mapvarydest= hair= portmap=UC derp=0",
want: "udp=true v4=false v6=false mapvarydest= portmap=UC derp=0",
},
}
for _, tt := range tests {

View File

@@ -59,6 +59,10 @@ type Dialer struct {
// If nil, it's not used.
NetstackDialTCP func(context.Context, netip.AddrPort) (net.Conn, error)
// NetstackDialUDP dials the provided IPPort using netstack.
// If nil, it's not used.
NetstackDialUDP func(context.Context, netip.AddrPort) (net.Conn, error)
peerClientOnce sync.Once
peerClient *http.Client
@@ -403,9 +407,12 @@ func (d *Dialer) UserDial(ctx context.Context, network, addr string) (net.Conn,
return nil, err
}
if d.UseNetstackForIP != nil && d.UseNetstackForIP(ipp.Addr()) {
if d.NetstackDialTCP == nil {
if d.NetstackDialTCP == nil || d.NetstackDialUDP == nil {
return nil, errors.New("Dialer not initialized correctly")
}
if strings.HasPrefix(network, "udp") {
return d.NetstackDialUDP(ctx, ipp)
}
return d.NetstackDialTCP(ctx, ipp)
}

9
omit/aws_def.go Normal file
View File

@@ -0,0 +1,9 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_aws
package omit
// AWS is whether AWS support should be omitted from the build.
const AWS = false

9
omit/aws_omit.go Normal file
View File

@@ -0,0 +1,9 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_aws
package omit
// AWS is whether AWS support should be omitted from the build.
const AWS = true

12
omit/omit.go Normal file
View File

@@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package omit provides consts to access Tailscale ts_omit_FOO build tags.
// They're often more convenient to eliminate some away locally with a const
// rather than using build tags.
package omit
import "errors"
// Err is an error that can be returned by functions in this package.
var Err = errors.New("feature not linked into binary per ts_omit build tag")

View File

@@ -23,6 +23,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/net/netmon"
"tailscale.com/net/stun"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
@@ -544,7 +545,7 @@ func newConn(ctx context.Context, dm *tailcfg.DERPMap, n *tailcfg.DERPNode, isPr
return !strings.Contains(s, "derphttp.Client.Connect: connecting to")
})
priv := key.NewNode()
dc := derphttp.NewRegionClient(priv, l, nil /* no netMon */, func() *tailcfg.DERPRegion {
dc := derphttp.NewRegionClient(priv, l, netmon.NewStatic(), func() *tailcfg.DERPRegion {
rid := n.RegionID
return &tailcfg.DERPRegion{
RegionID: rid,

891
publicapi/device.md Normal file
View File

@@ -0,0 +1,891 @@
# Device
A Tailscale device (sometimes referred to as _node_ or _machine_), is any computer or mobile device that joins a tailnet.
Each device has a unique ID (`nodeId` in the JSON below) that is used to identify the device in API calls.
This ID can be found by going to the [**Machines**](https://login.tailscale.com/admin/machines) page in the admin console,
selecting the relevant device, then finding the ID in the Machine Details section.
You can also [list all devices in the tailnet](#list-tailnet-devices) to get their `nodeId` values.
(A device's numeric `id` value can also be used in API calls, but `nodeId` is preferred.)
### Attributes
```jsonc
{
// addresses (array of strings) is a list of Tailscale IP
// addresses for the device, including both IPv4 (formatted as 100.x.y.z)
// and IPv6 (formatted as fd7a:115c:a1e0:a:b:c:d:e) addresses.
"addresses": ["100.87.74.78", "fd7a:115c:a1e0:ac82:4843:ca90:697d:c36e"],
// id (string) is the legacy identifier for a device; you
// can supply this value wherever {deviceId} is indicated in the
// endpoint. Note that although "id" is still accepted, "nodeId" is
// preferred.
"id": "393735751060",
// nodeID (string) is the preferred identifier for a device;
// supply this value wherever {deviceId} is indicated in the endpoint.
"nodeId": "n5SUKe8CNTRL",
// user (string) is the user who registered the node. For untagged nodes,
// this user is the device owner.
"user": "amelie@example.com",
// name (string) is the MagicDNS name of the device.
// Learn more about MagicDNS at https://tailscale.com/kb/1081/.
"name": "pangolin.tailfe8c.ts.net",
// hostname (string) is the machine name in the admin console
// Learn more about machine names at https://tailscale.com/kb/1098/.
"hostname": "pangolin",
// clientVersion (string) is the version of the Tailscale client
// software; this is empty for external devices.
"clientVersion": "",
// updateAvailable (boolean) is 'true' if a Tailscale client version
// upgrade is available. This value is empty for external devices.
"updateAvailable": false,
// os (string) is the operating system that the device is running.
"os": "linux",
// created (string) is the date on which the device was added
// to the tailnet; this is empty for external devices.
"created": "2022-12-01T05:23:30Z",
// lastSeen (string) is when device was last active on the tailnet.
"lastSeen": "2022-12-01T05:23:30Z",
// keyExpiryDisabled (boolean) is 'true' if the keys for the device
// will not expire. Learn more at https://tailscale.com/kb/1028/.
"keyExpiryDisabled": true,
// expires (string) is the expiration date of the device's auth key.
// Learn more about key expiry at https://tailscale.com/kb/1028/.
"expires": "2023-05-30T04:44:05Z",
// authorized (boolean) is 'true' if the device has been
// authorized to join the tailnet; otherwise, 'false'. Learn
// more about device authorization at https://tailscale.com/kb/1099/.
"authorized": true,
// isExternal (boolean) if 'true', indicates that a device is not
// a member of the tailnet, but is shared in to the tailnet;
// if 'false', the device is a member of the tailnet.
// Learn more about node sharing at https://tailscale.com/kb/1084/.
"isExternal": true,
// machineKey (string) is for internal use and is not required for
// any API operations. This value is empty for external devices.
"machineKey": "",
// nodeKey (string) is mostly for internal use, required for select
// operations, such as adding a node to a locked tailnet.
// Learn about tailnet locks at https://tailscale.com/kb/1226/.
"nodeKey": "nodekey:01234567890abcdef",
// blocksIncomingConnections (boolean) is 'true' if the device is not
// allowed to accept any connections over Tailscale, including pings.
// Learn more in the "Allow incoming connections"
// section of https://tailscale.com/kb/1072/.
"blocksIncomingConnections": false,
// enabledRoutes (array of strings) are the subnet routes for this
// device that have been approved by the tailnet admin.
// Learn more about subnet routes at https://tailscale.com/kb/1019/.
"enabledRoutes": ["10.0.0.0/16", "192.168.1.0/24"],
// advertisedRoutes (array of strings) are the subnets this device
// intends to expose.
// Learn more about subnet routes at https://tailscale.com/kb/1019/.
"advertisedRoutes": ["10.0.0.0/16", "192.168.1.0/24"],
// clientConnectivity provides a report on the device's current physical
// network conditions.
"clientConnectivity": {
// endpoints (array of strings) Client's magicsock UDP IP:port
// endpoints (IPv4 or IPv6)
"endpoints": ["199.9.14.201:59128", "192.68.0.21:59128"],
// mappingVariesByDestIP (boolean) is 'true' if the host's NAT mappings
// vary based on the destination IP.
"mappingVariesByDestIP": false,
// latency (JSON object) lists DERP server locations and their current
// latency; "preferred" is 'true' for the node's preferred DERP
// server for incoming traffic.
"latency": {
"Dallas": {
"latencyMs": 60.463043
},
"New York City": {
"preferred": true,
"latencyMs": 31.323811
}
},
// clientSupports (JSON object) identifies features supported by the client.
"clientSupports": {
// hairpinning (boolean) is 'true' if your router can route connections
// from endpoints on your LAN back to your LAN using those endpoints
// globally-mapped IPv4 addresses/ports
"hairPinning": false,
// ipv6 (boolean) is 'true' if the device OS supports IPv6,
// regardless of whether IPv6 internet connectivity is available.
"ipv6": false,
// pcp (boolean) is 'true' if PCP port-mapping service exists on
// your router.
"pcp": false,
// pmp (boolean) is 'true' if NAT-PMP port-mapping service exists
// on your router.
"pmp": false,
// udp (boolean) is 'true' if UDP traffic is enabled on the
// current network; if 'false', Tailscale may be unable to make
// direct connections, and will rely on our DERP servers.
"udp": true,
// upnp (boolean) is 'true' if UPnP port-mapping service exists
// on your router.
"upnp": false
}
},
// tags (array of strings) let you assign an identity to a device that
// is separate from human users, and use it as part of an ACL to restrict
// access. Once a device is tagged, the tag is the owner of that device.
// A single node can have multiple tags assigned. This value is empty for
// external devices.
// Learn more about tags at https://tailscale.com/kb/1068/.
"tags": ["tag:golink"],
// tailnetLockError (string) indicates an issue with the tailnet lock
// node-key signature on this device.
// This field is only populated when tailnet lock is enabled.
"tailnetLockError": "",
// tailnetLockKey (string) is the node's tailnet lock key. Every node
// generates a tailnet lock key (so the value will be present) even if
// tailnet lock is not enabled.
// Learn more about tailnet lock at https://tailscale.com/kb/1226/.
"tailnetLockKey": "",
// postureIdentity contains extra identifiers from the device when the tailnet
// it is connected to has device posture identification collection enabled.
// If the device has not opted-in to posture identification collection, this
// will contain {"disabled": true}.
// Learn more about posture identity at https://tailscale.com/kb/1326/device-identity
"postureIdentity": {
"serialNumbers": ["CP74LFQJXM"]
}
}
```
# APIs
**[Device](#device)**
- Get a device: [`GET /api/v2/device/{deviceid}`](#get-device)
- Delete a device: [`DELETE /api/v2/device/{deviceID}`](#delete-device)
- Expire device key: [`POST /api/v2/device/{deviceID}/expire`](#expire-device-key)
- [**Routes**](#routes)
- Get device routes: [`GET /api/v2/device/{deviceID}/routes`](#get-device-routes)
- Set device routes: [`POST /api/v2/device/{deviceID}/routes`](#set-device-routes)
- [**Authorize**](#authorize)
- Authorize a device: [`POST /api/v2/device/{deviceID}/authorized`](#authorize-device)
- [**Tags**](#tags)
- Update tags: [`POST /api/v2/device/{deviceID}/tags`](#update-device-tags)
- [**Keys**](#keys)
- Update device key: [`POST /api/v2/device/{deviceID}/key`](#update-device-key)
- [**IP Addresses**](#ip-addresses)
- Set device IPv4 address: [`POST /api/v2/device/{deviceID}/ip`](#set-device-ipv4-address)
- [**Device posture attributes**](#device-posture-attributes)
- Get device posture attributes: [`GET /api/v2/device/{deviceID}/attributes`](#get-device-posture-attributes)
- Set custom device posture attributes: [`POST /api/v2/device/{deviceID}/attributes/{attributeKey}`](#set-device-posture-attributes)
- Delete custom device posture attributes: [`DELETE /api/v2/device/{deviceID}/attributes/{attributeKey}`](#delete-custom-device-posture-attributes)
- [**Device invites**](#invites-to-a-device)
- List device invites: [`GET /api/v2/device/{deviceID}/device-invites`](#list-device-invites)
- Create device invites: [`POST /api/v2/device/{deviceID}/device-invites`](#create-device-invites)
### Subnet routes
Devices within a tailnet can be set up as subnet routers.
A subnet router acts as a gateway, relaying traffic from your Tailscale network onto your physical subnet.
Setting up subnet routers exposes routes to other devices in the tailnet.
Learn more about [subnet routers](https://tailscale.com/kb/1019).
A device can act as a subnet router if its subnet routes are both advertised and enabled.
This is a two-step process, but the steps can occur in any order:
- The device that intends to act as a subnet router exposes its routes by **advertising** them.
This is done in the Tailscale command-line interface.
- The tailnet admin must approve the routes by **enabling** them.
This is done in the [**Machines**](https://login.tailscale.com/admin/machines) page of the Tailscale admin console
or [via the API](#set-device-routes).
If a device has advertised routes, they are not exposed to traffic until they are enabled by the tailnet admin.
Conversely, if a tailnet admin pre-approves certain routes by enabling them, they are not available for routing until the device in question has advertised them.
The API exposes two methods for dealing with subnet routes:
- Get routes: [`GET /api/v2/device/{deviceID}/routes`](#get-device-routes) to fetch lists of advertised and enabled routes for a device
- Set routes: [`POST /api/v2/device/{deviceID}/routes`](#set-device-routes) to set enabled routes for a device
## Get device
```http
GET /api/v2/device/{deviceid}
```
Retrieve the details for the specified device.
This returns a JSON `device` object listing device attributes.
### Parameters
#### `deviceid` (required in URL path)
The ID of the device.
#### `fields` (optional in query string)
Controls whether the response returns **all** object fields or only a predefined subset of fields.
Currently, there are two supported options:
- **`all`:** return all object fields in the response
- **`default`:** return all object fields **except**:
- `enabledRoutes`
- `advertisedRoutes`
- `clientConnectivity` (which contains the following fields: `mappingVariesByDestIP`, `derp`, `endpoints`, `latency`, and `clientSupports`)
- `postureIdentity`
### Request example
```sh
curl "https://api.tailscale.com/api/v2/device/12345?fields=all" \
-u "tskey-api-xxxxx:"
```
### Response
```jsonc
{
"addresses":[
"100.71.74.78",
"fd7a:115c:a1e0:ac82:4843:ca90:697d:c36e"
],
"id":"12345",
// Additional fields as documented in device "Attributes" section above
}
{
"addresses":[
"100.74.66.78",
"fd7a:115c:a1e0:ac82:4843:ca90:697d:c36f"
],
"id":"67890",
// Additional fields as documented in device "Attributes" section above
}
```
## Delete device
```http
DELETE /api/v2/device/{deviceID}
```
Deletes the supplied device from its tailnet.
The device must belong to the user's tailnet.
Deleting shared/external devices is not supported.
### Parameters
#### `deviceid` (required in URL path)
The ID of the device.
### Request example
```sh
curl -X DELETE 'https://api.tailscale.com/api/v2/device/12345' \
-u "tskey-api-xxxxx:"
```
### Response
If successful, the response should be empty:
```http
HTTP/1.1 200 OK
```
If the device is not owned by your tailnet:
```http
HTTP/1.1 501 Not Implemented
...
{"message":"cannot delete devices outside of your tailnet"}
```
## Expire a device's key
```http
POST /api/v2/device/{deviceID}/expire
```
Mark a device's node key as expired.
This will require the device to re-authenticate in order to connect to the tailnet.
The device must belong to the requesting user's tailnet.
### Parameters
#### `deviceid` (required in URL path)
The ID of the device.
### Request example
```sh
curl -X POST 'https://api.tailscale.com/api/v2/device/12345/expire' \
-u "tskey-api-xxxxx:" \
-H "Content-Type: application/json"
```
### Response
If successful, the response should be empty:
```http
HTTP/1.1 200 OK
```
## Routes
## Get device routes
```http
GET /api/v2/device/{deviceID}/routes
```
Retrieve the list of [subnet routes](#subnet-routes) that a device is advertising, as well as those that are enabled for it:
- **Enabled routes:** The subnet routes for this device that have been approved by the tailnet admin.
- **Advertised routes:** The subnets this device intends to expose.
### Parameters
#### `deviceid` (required in URL path)
The ID of the device.
### Request example
```sh
curl "https://api.tailscale.com/api/v2/device/11055/routes" \
-u "tskey-api-xxxxx:"
```
### Response
Returns the enabled and advertised subnet routes for a device.
```jsonc
{
"advertisedRoutes": ["10.0.0.0/16", "192.168.1.0/24"],
"enabledRoutes": []
}
```
## Set device routes
```http
POST /api/v2/device/{deviceID}/routes
```
Sets a device's enabled [subnet routes](#subnet-routes) by replacing the existing list of subnet routes with the supplied parameters.
Advertised routes cannot be set through the API, since they must be set directly on the device.
### Parameters
#### `deviceid` (required in URL path)
The ID of the device.
#### `routes` (required in `POST` body)
The new list of enabled subnet routes.
```jsonc
{
"routes": ["10.0.0.0/16", "192.168.1.0/24"]
}
```
### Request example
```sh
curl "https://api.tailscale.com/api/v2/device/11055/routes" \
-u "tskey-api-xxxxx:" \
-H "Content-Type: application/json" \
--data-binary '{"routes": ["10.0.0.0/16", "192.168.1.0/24"]}'
```
### Response
Returns the enabled and advertised subnet routes for a device.
```jsonc
{
"advertisedRoutes": ["10.0.0.0/16", "192.168.1.0/24"],
"enabledRoutes": ["10.0.0.0/16", "192.168.1.0/24"]
}
```
## Authorize
## Authorize device
```http
POST /api/v2/device/{deviceID}/authorized
```
Authorize a device.
This call marks a device as authorized or revokes its authorization for tailnets where device authorization is required, according to the `authorized` field in the payload.
This returns a successful 2xx response with an empty JSON object in the response body.
### Parameters
#### `deviceid` (required in URL path)
The ID of the device.
#### `authorized` (required in `POST` body)
Specify whether the device is authorized. False to deauthorize an authorized device, and true to authorize a new device or to re-authorize a previously deauthorized device.
```jsonc
{
"authorized": true
}
```
### Request example
```sh
curl "https://api.tailscale.com/api/v2/device/11055/authorized" \
-u "tskey-api-xxxxx:" \
-H "Content-Type: application/json" \
--data-binary '{"authorized": true}'
```
### Response
The response is 2xx on success. The response body is currently an empty JSON object.
## Tags
## Update device tags
```http
POST /api/v2/device/{deviceID}/tags
```
Update the tags set on a device.
Tags let you assign an identity to a device that is separate from human users, and use that identity as part of an ACL to restrict access.
Tags are similar to role accounts, but more flexible.
Tags are created in the tailnet policy file by defining the tag and an owner of the tag.
Once a device is tagged, the tag is the owner of that device.
A single node can have multiple tags assigned.
Consult the policy file for your tailnet in the [admin console](https://login.tailscale.com/admin/acls) for the list of tags that have been created for your tailnet.
Learn more about [tags](https://tailscale.com/kb/1068/).
This returns a 2xx code if successful, with an empty JSON object in the response body.
### Parameters
#### `deviceid` (required in URL path)
The ID of the device.
#### `tags` (required in `POST` body)
The new list of tags for the device.
```jsonc
{
"tags": ["tag:foo", "tag:bar"]
}
```
### Request example
```sh
curl "https://api.tailscale.com/api/v2/device/11055/tags" \
-u "tskey-api-xxxxx:" \
-H "Content-Type: application/json" \
--data-binary '{"tags": ["tag:foo", "tag:bar"]}'
```
### Response
The response is 2xx on success. The response body is currently an empty JSON object.
If the tags supplied in the `POST` call do not exist in the tailnet policy file, the response is '400 Bad Request':
```jsonc
{
"message": "requested tags [tag:madeup tag:wrongexample] are invalid or not permitted"
}
```
## Keys
## Update device key
```http
POST /api/v2/device/{deviceID}/key
```
Update properties of the device key.
### Parameters
#### `deviceid` (required in URL path)
The ID of the device.
#### `keyExpiryDisabled` (optional in `POST` body)
Disable or enable the expiry of the device's node key.
When a device is added to a tailnet, its key expiry is set according to the tailnet's [key expiry](https://tailscale.com/kb/1028/) setting.
If the key is not refreshed and expires, the device can no longer communicate with other devices in the tailnet.
Set `"keyExpiryDisabled": true` to disable key expiry for the device and allow it to rejoin the tailnet (for example to access an accidentally expired device).
You can then call this method again with `"keyExpiryDisabled": false` to re-enable expiry.
```jsonc
{
"keyExpiryDisabled": true
}
```
- If `true`, disable the device's key expiry.
The original key expiry time is still maintained.
Upon re-enabling, the key will expire at that original time.
- If `false`, enable the device's key expiry.
Sets the key to expire at the original expiry time prior to disabling.
The key may already have expired. In that case, the device must be re-authenticated.
- Empty value will not change the key expiry.
This returns a 2xx code on success, with an empty JSON object in the response body.
### Request example
```sh
curl "https://api.tailscale.com/api/v2/device/11055/key" \
-u "tskey-api-xxxxx:" \
-H "Content-Type: application/json" \
--data-binary '{"keyExpiryDisabled": true}'
```
### Response
The response is 2xx on success. The response body is currently an empty JSON object.
## IP Addresses
## Set device IPv4 address
```http
POST /api/v2/device/{deviceID}/ip
```
Set the Tailscale IPv4 address of the device.
### Parameters
#### `deviceid` (required in URL path)
The ID of the device.
#### `ipv4` (optional in `POST` body)
Provide a new IPv4 address for the device.
When a device is added to a tailnet, its Tailscale IPv4 address is set at random either from the CGNAT range, or a subset of the CGNAT range specified by an [ip pool](https://tailscale.com/kb/1304/ip-pool).
This endpoint can be used to replace the existing IPv4 address with a specific value.
```jsonc
{
"ipv4": "100.80.0.1"
}
```
This action will break any existing connections to this machine.
You will need to reconnect to this machine using the new IP address.
You may also need to flush your DNS cache.
This returns a 2xx code on success, with an empty JSON object in the response body.
### Request example
```sh
curl "https://api.tailscale.com/api/v2/device/11055/ip" \
-u "tskey-api-xxxxx:" \
-H "Content-Type: application/json" \
--data-binary '{"ipv4": "100.80.0.1"}'
```
### Response
The response is 2xx on success. The response body is currently an empty JSON object.
## Device posture attributes
## Get device posture attributes
The posture attributes API endpoints can be called with OAuth access tokens with
an `acl` or `devices` [scope](https://tailscale.com/kb/1215/oauth-clients#scopes), or personal access belonging to
[user roles](https://tailscale.com/kb/1138/user-roles) Owners, Admins, Network Admins, or IT Admins.
```
GET /api/v2/device/{deviceID}/attributes
```
Retrieve all posture attributes for the specified device. This returns a JSON object of all the key-value pairs of posture attributes for the device.
### Parameters
#### `deviceID` (required in URL path)
The ID of the device to fetch posture attributes for.
### Request example
```
curl "https://api.tailscale.com/api/v2/device/11055/attributes" \
-u "tskey-api-xxxxx:"
```
### Response
The response is 200 on success. The response body is a JSON object containing all the posture attributes assigned to the node. Attribute values can be strings, numbers or booleans.
```json
{
"attributes": {
"custom:myScore": 87,
"custom:diskEncryption": true,
"custom:myAttribute": "my_value",
"node:os": "linux",
"node:osVersion": "5.19.0-42-generic",
"node:tsReleaseTrack": "stable",
"node:tsVersion": "1.40.0",
"node:tsAutoUpdate": false
}
}
```
## Set custom device posture attributes
```
POST /api/v2/device/{deviceID}/attributes/{attributeKey}
```
Create or update a custom posture attribute on the specified device. User-managed attributes must be in the `custom` namespace, which is indicated by prefixing the attribute key with `custom:`.
Custom device posture attributes are available for the Personal and Enterprise plans.
### Parameters
#### `deviceID` (required in URL path)
The ID of the device on which to set the custom posture attribute.
#### `attributeKey` (required in URL path)
The name of the posture attribute to set. This must be prefixed with `custom:`.
Keys have a maximum length of 50 characters including the namespace, and can only contain letters, numbers, underscores, and colon.
Keys are case-sensitive. Keys must be unique, but are checked for uniqueness in a case-insensitive manner. For example, `custom:MyAttribute` and `custom:myattribute` cannot both be set within a single tailnet.
All values for a given key need to be of the same type, which is determined when the first value is written for a given key. For example, `custom:myattribute` cannot have a numeric value (`87`) for one node and a string value (`"78"`) for another node within the same tailnet.
### Posture attribute `value` (required in POST body)
```json
{
"value": "foo"
}
```
A value can be either a string, number or boolean.
A string value can have a maximum length of 50 characters, and can only contain letters, numbers, underscores, and periods.
A number value is an integer and must be a JSON safe number (up to 2^53 - 1).
### Request example
```
curl "https://api.tailscale.com/api/v2/device/11055/attributes/custom:my_attribute" \
-u "tskey-api-xxxxx:" \
-H "Content-Type: application/json" \
--data-binary '{"value": "my_value"}'
```
### Response
The response is 2xx on success. The response body is currently an empty JSON object.
## Delete custom device posture attributes
```
DELETE /api/v2/device/{deviceID}/attributes/{attributeKey}
```
Delete a posture attribute from the specified device. This is only applicable to user-managed posture attributes in the `custom` namespace, which is indicated by prefixing the attribute key with `custom:`.
<PricingPlanNote feature="Custom device posture attributes" verb="are" plan="the Personal and Enterprise plans" />
### Parameters
#### `deviceID` (required in URL path)
The ID of the device from which to delete the posture attribute.
#### `attributeKey` (required in URL path)
The name of the posture attribute to delete. This must be prefixed with `custom:`.
Keys have a maximum length of 50 characters including the namespace, and can only contain letters, numbers, underscores, and a delimiting colon.
### Request example
```
curl -X DELETE "https://api.tailscale.com/api/v2/device/11055/attributes/custom:my_attribute" \
-u "tskey-api-xxxxx:"
```
### Response
The response is 2xx on success. The response body is currently an empty JSON object.
## Invites to a device
The device sharing invite methods let you create and list [invites to share a device](https://tailscale.com/kb/1084/sharing).
## List device invites
```http
GET /api/v2/device/{deviceID}/device-invites
```
List all share invites for a device.
### Parameters
#### `deviceID` (required in URL path)
The ID of the device.
### Request example
```sh
curl -X GET "https://api.tailscale.com/api/v2/device/11055/device-invites" \
-u "tskey-api-xxxxx:"
```
### Response
```jsonc
[
{
"id": "12345",
"created": "2024-05-08T20:19:51.777861756Z",
"tailnetId": 59954,
"deviceId": 11055,
"sharerId": 22011,
"allowExitNode": true,
"email": "user@example.com",
"lastEmailSentAt": "2024-05-08T20:19:51.777861756Z",
"inviteUrl": "https://login.tailscale.com/admin/invite/<code>",
"accepted": false
},
{
"id": "12346",
"created": "2024-04-03T21:38:49.333829261Z",
"tailnetId": 59954,
"deviceId": 11055,
"sharerId": 22012,
"inviteUrl": "https://login.tailscale.com/admin/invite/<code>",
"accepted": true,
"acceptedBy": {
"id": 33223,
"loginName": "someone@example.com",
"profilePicUrl": ""
}
}
]
```
## Create device invites
```http
POST /api/v2/device/{deviceID}/device-invites
```
Create new share invites for a device.
### Parameters
#### `deviceID` (required in URL path)
The ID of the device.
#### List of invite requests (required in `POST` body)
Each invite request is an object with the following optional fields:
- **`multiUse`:** (Optional) Specify whether the invite can be accepted more than once. When set to `true`, it results in an invite that can be accepted up to 1,000 times.
- **`allowExitNode`:** (Optional) Specify whether the invited user can use the device as an exit node when it advertises as one.
- **`email`:** (Optional) Specify the email to send the created invite. If not set, the endpoint generates and returns an invite URL (but doesn't send it out).
### Request example
```sh
curl -X POST "https://api.tailscale.com/api/v2/device/11055/device-invites" \
-u "tskey-api-xxxxx:" \
-H "Content-Type: application/json" \
--data-binary '[{"multiUse": true, "allowExitNode": true, "email":"user@example.com"}]'
```
### Response
```jsonc
[
{
"id": "12347",
"created": "2024-05-08T20:29:45.842358533Z",
"tailnetId": 59954,
"deviceId": 11055,
"sharerId": 22012,
"multiUse": true,
"allowExitNode": true,
"email": "user@example.com",
"lastEmailSentAt": "2024-05-08T20:29:45.842358533Z",
"inviteUrl": "https://login.tailscale.com/admin/invite/<code>",
"accepted": false
}
]
```

221
publicapi/deviceinvites.md Normal file
View File

@@ -0,0 +1,221 @@
# Device invites
A device invite is an invitation that shares a device with an external user (a user not in the device's tailnet).
Each device invite has a unique ID that is used to identify the invite in API calls.
You can find all device invite IDs for a particular device by [listing all device invites for a device](#list-device-invites).
### Attributes
```jsonc
{
// id (strings) is the unique identifier for the invite.
// Supply this value wherever {deviceInviteId} is indicated in the endpoint.
"id": "12346",
// created is the creation time of the invite.
"created": "2024-04-03T21:38:49.333829261Z",
// tailnetId is the ID of the tailnet to which the shared device belongs.
"tailnetId": 59954,
// deviceId is the ID of the device being shared.
"deviceId": 11055,
// sharerId is the ID of the user who created the share invite.
"sharerId": 22012,
// multiUse specifies whether this device invite can be accepted more than
// once.
"multiUse": false,
// allowExitNode specifies whether the invited user is able to use the
// device as an exit node when the device is advertising as one.
"allowExitNode": true,
// email is the email to which the invite was sent.
// If empty, the invite was not emailed to anyone, but the inviteUrl can be
// shared manually.
"email": "user@example.com",
// lastEmailSentAt is the last time the invite was attempted to be sent to
// Email. Only ever set if Email is not empty.
"lastEmailSentAt": "2024-04-03T21:38:49.333829261Z",
// inviteUrl is the link to accept the invite.
// Anyone with this link can accept the invite.
// It is not restricted to the person to which the invite was emailed.
"inviteUrl": "https://login.tailscale.com/admin/invite/<code>",
// accepted is true when share invite has been accepted.
"accepted": true,
// acceptedBy is set when the invite has been accepted.
// It holds information about the user who accepted the share invite.
"acceptedBy": {
// id is the ID of the user who accepted the share invite.
"id": 33223,
// loginName is the login name of the user who accepted the share invite.
"loginName": "someone@example.com",
// profilePicUrl is optionally the profile pic URL for the user who accepted
// the share invite.
"profilePicUrl": ""
}
}
```
# API
**[Device invites](#device-invites)**
- Get device invite: [`GET /api/v2/device-invites/{deviceInviteId}`](#get-device-invite)
- Delete device invite: [`DELETE /api/v2/device-invites/{deviceInviteId}`](#delete-device-invite)
- Resend device invite (by email): [`POST /api/v2/device-invites/{deviceInviteId}/resend`](#resend-device-invite)
- Accept device invite [`POST /api/v2/device-invites/-/accept`](#accept-device-invite)
## Get device invite
```http
GET /api/v2/device-invites/{deviceInviteId}
```
Retrieve the specified device invite.
### Parameters
#### `deviceInviteId` (required in URL path)
The ID of the device share invite.
### Request example
```sh
curl "https://api.tailscale.com/api/v2/device-invites/12346" \
-u "tskey-api-xxxxx:"
```
### Response
```jsonc
{
"id": "12346",
"created": "2024-04-03T21:38:49.333829261Z",
"tailnetId": 59954,
"deviceId": 11055,
"sharerId": 22012,
"multiUse": true,
"allowExitNode": true,
"email": "user@example.com",
"lastEmailSentAt": "2024-04-03T21:38:49.333829261Z",
"inviteUrl": "https://login.tailscale.com/admin/invite/<code>",
"accepted": false
}
```
## Delete device invite
```http
DELETE /api/v2/device-invites/{deviceInviteId}
```
Delete the specified device invite.
### Parameters
#### `deviceInviteId` (required in URL path)
The ID of the device share invite.
### Request example
```sh
curl -X DELETE "https://api.tailscale.com/api/v2/device-invites/12346" \
-u "tskey-api-xxxxx:"
```
### Response
The response is 2xx on success. The response body is an empty JSON object.
## Resend device invite
```http
POST /api/v2/device-invites/{deviceInviteId}/resend
```
Resend the specified device invite by email. You can only use this if the specified invite was originally created with an email specified. Refer to [creating device invites for a device](#create-device-invites).
Note: Invite resends are rate limited to one per minute.
### Parameters
#### `deviceInviteId` (required in URL path)
The ID of the device share invite.
### Request example
```sh
curl -X POST "https://api.tailscale.com/api/v2/device-invites/12346/resend" \
-u "tskey-api-xxxxx:"
```
### Response
The response is 2xx on success. The response body is an empty JSON object.
## Accept device invite
```http
POST /api/v2/device-invites/-/accept
```
Resend the specified device invite by email. This can only be used if the specified invite was originally created with an email specified.
See [creating device invites for a device](#create-device-invites).
Note that invite resends are rate limited to once per minute.
### Parameters
#### `invite` (required in `POST` body)
The URL of the invite (in the form "https://login.tailscale.com/admin/invite/{code}") or the "{code}" component of the URL.
### Request example
```sh
curl -X POST "https://api.tailscale.com/api/v2/device-invites/-/accept" \
-u "tskey-api-xxxxx:" \
-H "Content-Type: application/json" \
--data-binary '[{"invite": "https://login.tailscale.com/admin/invite/xxxxxx"}]'
```
### Response
```jsonc
{
"device": {
"id": "11055",
"os": "iOS",
"name": "my-phone",
"fqdn": "my-phone.something.ts.net",
"ipv4": "100.x.y.z",
"ipv6": "fd7a:115c:x::y:z",
"includeExitNode": false
},
"sharer": {
"id": "22012",
"displayName": "Some User",
"loginName": "someuser@example.com",
"profilePicURL": ""
},
"acceptedBy": {
"id": "33233",
"displayName": "Another User",
"loginName": "anotheruser@exmaple2.com",
"profilePicURL": ""
}
}
```

118
publicapi/readme.md Normal file
View File

@@ -0,0 +1,118 @@
# Tailscale API
The Tailscale API is a (mostly) RESTful API. Typically, both `POST` bodies and responses are JSON-encoded.
## Base URL
The base URL for the Tailscale API is `https://api.tailscale.com/api/v2/`.
Examples in this document may abbreviate this to `/api/v2/`.
## Authentication
Requests to the Tailscale API are authenticated with an API access token (sometimes called an API key).
Access tokens can be supplied as the username portion of HTTP Basic authentication (leave the password blank) or as an OAuth Bearer token:
```sh
# passing token with basic auth
curl -u "tskey-api-xxxxx:" https://api.tailscale.com/api/v2/...
# passing token as bearer token
curl -H "Authorization: Bearer tskey-api-xxxxx" https://api.tailscale.com/api/v2/...
```
Access tokens for individual users can be created and managed from the [**Keys**](https://login.tailscale.com/admin/settings/keys) page of the admin console.
These tokens will have the same permissions as the owning user, and can be set to expire in 1 to 90 days.
Access tokens are identifiable by the prefix `tskey-api-`.
Alternatively, an OAuth client can be used to create short-lived access tokens with scoped permission.
OAuth clients don't expire, and can therefore be used to provide ongoing access to the API, creating access tokens as needed.
OAuth clients and the access tokens they create are not tied to an individual Tailscale user.
OAuth client secrets are identifiable by the prefix `tskey-client-`.
Learn more about [OAuth clients](https://tailscale.com/kb/1215/).
## Errors
The Tailscale API returns status codes consistent with [standard HTTP conventions](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status).
In addition to the status code, errors may include additional information in the response body:
```jsonc
{
"message": "additional error information"
}
```
## Pagination
The Tailscale API does not currently support pagination. All results are returned at once.
# APIs
**[Device](./device.md#device)**
- Get a device: [`GET /api/v2/device/{deviceid}`](./device.md#get-device)
- Delete a device: [`DELETE /api/v2/device/{deviceID}`](./device.md#delete-device)
- Expire device key: [`POST /api/v2/device/{deviceID}/expire`](./device.md#expire-device-key)
- [**Routes**](./device.md#routes)
- Get device routes: [`GET /api/v2/device/{deviceID}/routes`](./device.md#get-device-routes)
- Set device routes: [`POST /api/v2/device/{deviceID}/routes`](./device.md#set-device-routes)
- [**Authorize**](./device.md#authorize)
- Authorize a device: [`POST /api/v2/device/{deviceID}/authorized`](./device.md#authorize-device)
- [**Tags**](./device.md#tags)
- Update tags: [`POST /api/v2/device/{deviceID}/tags`](./device.md#update-device-tags)
- [**Keys**](./device.md#keys)
- Update device key: [`POST /api/v2/device/{deviceID}/key`](./device.md#update-device-key)
- [**IP Addresses**](./device.md#ip-addresses)
- Set device IPv4 address: [`POST /api/v2/device/{deviceID}/ip`](./device.md#set-device-ipv4-address)
- [**Device posture attributes**](./device.md#device-posture-attributes)
- Get device posture attributes: [`GET /api/v2/device/{deviceID}/attributes`](./device.md#get-device-posture-attributes)
- Set custom device posture attributes: [`POST /api/v2/device/{deviceID}/attributes/{attributeKey}`](./device.md#set-device-posture-attributes)
- Delete custom device posture attributes: [`DELETE /api/v2/device/{deviceID}/attributes/{attributeKey}`](./device.md#delete-custom-device-posture-attributes)
- [**Device invites**](./device.md#invites-to-a-device)
- List device invites: [`GET /api/v2/device/{deviceID}/device-invites`](./device.md#list-device-invites)
- Create device invites: [`POST /api/v2/device/{deviceID}/device-invites`](./device.md#create-device-invites)
**[Tailnet](./tailnet.md#tailnet)**
- [**Policy File**](./tailnet.md#policy-file)
- Get policy file: [`GET /api/v2/tailnet/{tailnet}/acl`](./tailnet.md#get-policy-file)
- Update policy file: [`POST /api/v2/tailnet/{tailnet}/acl`](./tailnet.md#update-policy-file)
- Preview rule matches: [`POST /api/v2/tailnet/{tailnet}/acl/preview`](./tailnet.md#preview-policy-file-rule-matches)
- Validate and test policy file: [`POST /api/v2/tailnet/{tailnet}/acl/validate`](./tailnet.md#validate-and-test-policy-file)
- [**Devices**](./tailnet.md#devices)
- List tailnet devices: [`GET /api/v2/tailnet/{tailnet}/devices`](./tailnet.md#list-tailnet-devices)
- [**Keys**](./tailnet.md#tailnet-keys)
- List tailnet keys: [`GET /api/v2/tailnet/{tailnet}/keys`](./tailnet.md#list-tailnet-keys)
- Create an auth key: [`POST /api/v2/tailnet/{tailnet}/keys`](./tailnet.md#create-auth-key)
- Get a key: [`GET /api/v2/tailnet/{tailnet}/keys/{keyid}`](./tailnet.md#get-key)
- Delete a key: [`DELETE /api/v2/tailnet/{tailnet}/keys/{keyid}`](./tailnet.md#delete-key)
- [**DNS**](./tailnet.md#dns)
- [**Nameservers**](./tailnet.md#nameservers)
- Get nameservers: [`GET /api/v2/tailnet/{tailnet}/dns/nameservers`](./tailnet.md#get-nameservers)
- Set nameservers: [`POST /api/v2/tailnet/{tailnet}/dns/nameservers`](./tailnet.md#set-nameservers)
- [**Preferences**](./tailnet.md#preferences)
- Get DNS preferences: [`GET /api/v2/tailnet/{tailnet}/dns/preferences`](./tailnet.md#get-dns-preferences)
- Set DNS preferences: [`POST /api/v2/tailnet/{tailnet}/dns/preferences`](./tailnet.md#set-dns-preferences)
- [**Search Paths**](./tailnet.md#search-paths)
- Get search paths: [`GET /api/v2/tailnet/{tailnet}/dns/searchpaths`](./tailnet.md#get-search-paths)
- Set search paths: [`POST /api/v2/tailnet/{tailnet}/dns/searchpaths`](./tailnet.md#set-search-paths)
- [**Split DNS**](./tailnet.md#split-dns)
- Get split DNS: [`GET /api/v2/tailnet/{tailnet}/dns/split-dns`](./tailnet.md#get-split-dns)
- Update split DNS: [`PATCH /api/v2/tailnet/{tailnet}/dns/split-dns`](./tailnet.md#update-split-dns)
- Set split DNS: [`PUT /api/v2/tailnet/{tailnet}/dns/split-dns`](./tailnet.md#set-split-dns)
- [**User invites**](./tailnet.md#tailnet-user-invites)
- List user invites: [`GET /api/v2/tailnet/{tailnet}/user-invites`](./tailnet.md#list-user-invites)
- Create user invites: [`POST /api/v2/tailnet/{tailnet}/user-invites`](./tailnet.md#create-user-invites)
**[User invites](./userinvites.md#user-invites)**
- Get user invite: [`GET /api/v2/user-invites/{userInviteId}`](./userinvites.md#get-user-invite)
- Delete user invite: [`DELETE /api/v2/user-invites/{userInviteId}`](./userinvites.md#delete-user-invite)
- Resend user invite (by email): [`POST /api/v2/user-invites/{userInviteId}/resend`](#resend-user-invite)
**[Device invites](./deviceinvites.md#device-invites)**
- Get device invite: [`GET /api/v2/device-invites/{deviceInviteId}`](./deviceinvites.md#get-device-invite)
- Delete device invite: [`DELETE /api/v2/device-invites/{deviceInviteId}`](./deviceinvites.md#delete-device-invite)
- Resend device invite (by email): [`POST /api/v2/device-invites/{deviceInviteId}/resend`](./deviceinvites.md#resend-device-invite)
- Accept device invite [`POST /api/v2/device-invites/-/accept`](#accept-device-invite)

1389
publicapi/tailnet.md Normal file

File diff suppressed because it is too large Load Diff

144
publicapi/userinvites.md Normal file
View File

@@ -0,0 +1,144 @@
# User invites
A user invite is an active invitation that lets a user join a tailnet with a pre-assigned [user role](https://tailscale.com/kb/1138/user-roles).
Each user invite has a unique ID that is used to identify the invite in API calls.
You can find all user invite IDs for a particular tailnet by [listing user invites](#list-user-invites).
### Attributes
```jsonc
{
// id (string) is the unique identifier for the invite.
// Supply this value wherever {userInviteId} is indicated in the endpoint.
"id": "12346",
// role is the tailnet user role to assign to the invited user upon accepting
// the invite. Value options are "member", "admin", "it-admin", "network-admin",
// "billing-admin", and "auditor".
"role": "admin",
// tailnetId is the ID of the tailnet to which the user was invited.
"tailnetId": 59954,
// inviterId is the ID of the user who created the invite.
"inviterId": 22012,
// email is the email to which the invite was sent.
// If empty, the invite was not emailed to anyone, but the inviteUrl can be
// shared manually.
"email": "user@example.com",
// lastEmailSentAt is the last time the invite was attempted to be sent to
// Email. Only ever set if `email` is not empty.
"lastEmailSentAt": "2024-04-03T21:38:49.333829261Z",
// inviteUrl is included when `email` is not part of the tailnet's domain,
// or when `email` is empty. It is the link to accept the invite.
//
// When included, anyone with this link can accept the invite.
// It is not restricted to the person to which the invite was emailed.
//
// When `email` is part of the tailnet's domain (has the same @domain.com
// suffix as the tailnet), the user can join the tailnet automatically by
// logging in with their domain email at https://login.tailscale.com/start.
// They'll be assigned the specified `role` upon signing in for the first
// time.
"inviteUrl": "https://login.tailscale.com/admin/invite/<code>"
}
```
# API
**[User invites](#user-invites)**
- Get user invite: [`GET /api/v2/user-invites/{userInviteId}`](#get-user-invite)
- Delete user invite: [`DELETE /api/v2/user-invites/{userInviteId}`](#delete-user-invite)
- Resend user invite (by email): [`POST /api/v2/user-invites/{userInviteId}/resend`](#resend-user-invite)
## Get user invite
```http
GET /api/v2/user-invites/{userInviteId}
```
Retrieve the specified user invite.
### Parameters
#### `userInviteId` (required in URL path)
The ID of the user invite.
### Request example
```sh
curl "https://api.tailscale.com/api/v2/user-invites/29214" \
-u "tskey-api-xxxxx:"
```
### Response
```jsonc
{
"id": "29214",
"role": "admin",
"tailnetId": 12345,
"inviterId": 34567,
"email": "user@example.com",
"lastEmailSentAt": "2024-05-09T16:23:26.91778771Z",
"inviteUrl": "https://login.tailscale.com/uinv/<code>"
}
```
## Delete user invite
```http
DELETE /api/v2/user-invites/{userInviteId}
```
Delete the specified user invite.
### Parameters
#### `userInviteId` (required in URL path)
The ID of the user invite.
### Request example
```sh
curl -X DELETE "https://api.tailscale.com/api/v2/user-invites/29214" \
-u "tskey-api-xxxxx:"
```
### Response
The response is 2xx on success. The response body is an empty JSON object.
## Resend user invite
```http
POST /api/v2/user-invites/{userInviteId}/resend
```
Resend the specified user invite by email. You can only use this if the specified invite was originally created with an email specified. Refer to [creating user invites for a tailnet](#create-user-invites).
Note: Invite resends are rate limited to one per minute.
### Parameters
#### `userInviteId` (required in URL path)
The ID of the user invite.
### Request example
```sh
curl -X POST "https://api.tailscale.com/api/v2/user-invites/29214/resend" \
-u "tskey-api-xxxxx:"
```
### Response
The response is 2xx on success. The response body is an empty JSON object.

View File

@@ -36,6 +36,7 @@ import (
"golang.org/x/sys/unix"
"tailscale.com/cmd/tailscaled/childproc"
"tailscale.com/hostinfo"
"tailscale.com/tailcfg"
"tailscale.com/tempfork/gliderlabs/ssh"
"tailscale.com/types/logger"
"tailscale.com/version/distro"
@@ -43,18 +44,22 @@ import (
func init() {
childproc.Add("ssh", beIncubator)
childproc.Add("sftp", beSFTP)
}
var ptyName = func(f *os.File) (string, error) {
return "", fmt.Errorf("unimplemented")
}
// maybeStartLoginSession starts a new login session for the specified UID.
// On success, it may return a non-nil close func which must be closed to
// maybeStartLoginSession informs the system that we are about to log someone
// in. On success, it may return a non-nil close func which must be closed to
// release the session.
// We can only do this if we are running as root.
// This is best effort to still allow running on machines where
// we don't support starting sessions, e.g. darwin.
// See maybeStartLoginSessionLinux.
var maybeStartLoginSession = func(logf logger.Logf, ia incubatorArgs) (close func() error, err error) {
return nil, nil
var maybeStartLoginSession = func(dlogf logger.Logf, ia incubatorArgs) (close func() error) {
return nil
}
// newIncubatorCommand returns a new exec.Cmd configured with
@@ -64,40 +69,39 @@ var maybeStartLoginSession = func(logf logger.Logf, ia incubatorArgs) (close fun
// exec.CommandContext.
//
// The returned Cmd.Env is guaranteed to be nil; the caller populates it.
func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err error) {
defer func() {
if cmd.Env != nil {
panic("internal error")
}
}()
var (
name string
args []string
isSFTP bool
isShell bool
)
var isSFTP, isShell bool
switch ss.Subsystem() {
case "sftp":
isSFTP = true
case "":
name = ss.conn.localUser.LoginShell()
if rawCmd := ss.RawCommand(); rawCmd != "" {
args = append(args, "-c", rawCmd)
} else {
isShell = true
args = append(args, "-l") // login shell
}
isShell = ss.RawCommand() == ""
default:
panic(fmt.Sprintf("unexpected subsystem: %v", ss.Subsystem()))
}
if ss.conn.srv.tailscaledPath == "" {
// TODO(maisem): this doesn't work with sftp
return exec.CommandContext(ss.ctx, name, args...)
if isSFTP {
// SFTP relies on the embedded Go-based SFTP server in tailscaled,
// so without tailscaled, we can't serve SFTP.
return nil, errors.New("no tailscaled found on path, can't serve SFTP")
}
loginShell := ss.conn.localUser.LoginShell()
args := shellArgs(isShell, ss.RawCommand())
logf("directly running %s %q", loginShell, args)
return exec.CommandContext(ss.ctx, loginShell, args...), nil
}
lu := ss.conn.localUser
ci := ss.conn.info
gids := strings.Join(ss.conn.userGroupIDs, ",")
groups := strings.Join(ss.conn.userGroupIDs, ",")
remoteUser := ci.uprof.LoginName
if ci.node.IsTagged() {
remoteUser = strings.Join(ci.node.Tags().AsSlice(), ",")
@@ -106,9 +110,10 @@ func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
incubatorArgs := []string{
"be-child",
"ssh",
"--login-shell=" + lu.LoginShell(),
"--uid=" + lu.Uid,
"--gid=" + lu.Gid,
"--groups=" + gids,
"--groups=" + groups,
"--local-user=" + lu.Username,
"--remote-user=" + remoteUser,
"--remote-ip=" + ci.src.Addr().String(),
@@ -116,39 +121,31 @@ func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
"--tty-name=", // updated in-place by startWithPTY
}
forceV1Behavior := ss.conn.srv.lb.NetMap().HasCap(tailcfg.NodeAttrSSHBehaviorV1)
if forceV1Behavior {
incubatorArgs = append(incubatorArgs, "--force-v1-behavior")
}
if debugTest.Load() {
incubatorArgs = append(incubatorArgs, "--debug-test")
}
if isSFTP {
incubatorArgs = append(incubatorArgs, "--sftp")
} else {
if isShell {
incubatorArgs = append(incubatorArgs, "--shell")
}
// Only the macOS version of the login command supports executing a
// command, all other versions only support launching a shell
// without taking any arguments.
shouldUseLoginCmd := isShell || runtime.GOOS == "darwin"
if hostinfo.IsSELinuxEnforcing() {
// If we're running on a SELinux-enabled system, the login
// command will be unable to set the correct context for the
// shell. Fall back to using the incubator to launch the shell.
// See http://github.com/tailscale/tailscale/issues/4908.
shouldUseLoginCmd = false
}
if shouldUseLoginCmd {
if lp, err := exec.LookPath("login"); err == nil {
incubatorArgs = append(incubatorArgs, "--login-cmd="+lp)
}
}
incubatorArgs = append(incubatorArgs, "--cmd="+name)
if len(args) > 0 {
incubatorArgs = append(incubatorArgs, "--")
incubatorArgs = append(incubatorArgs, args...)
}
switch {
case isSFTP:
// Note that we include both the `--sftp` flag and a command to launch
// tailscaled as `be-child sftp`. If login or su is available, and
// we're not running with tailcfg.NodeAttrSSHBehaviorV1, this will
// result in serving SFTP within a login shell, with full PAM
// integration. Otherwise, we'll serve SFTP in the incubator process
// with no PAM integration.
incubatorArgs = append(incubatorArgs, "--sftp", fmt.Sprintf("--cmd=%s be-child sftp", ss.conn.srv.tailscaledPath))
case isShell:
incubatorArgs = append(incubatorArgs, "--shell")
default:
incubatorArgs = append(incubatorArgs, "--cmd="+ss.RawCommand())
}
return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...)
return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...), nil
}
var debugIncubator bool
@@ -170,51 +167,60 @@ func (stdRWC) Close() error {
}
type incubatorArgs struct {
uid int
gid int
groups string
localUser string
remoteUser string
remoteIP string
ttyName string
hasTTY bool
cmdName string
isSFTP bool
isShell bool
loginCmdPath string
cmdArgs []string
debugTest bool
loginShell string
uid int
gid int
gids []int
localUser string
remoteUser string
remoteIP string
ttyName string
hasTTY bool
cmd string
isSFTP bool
isShell bool
forceV1Behavior bool
debugTest bool
}
func parseIncubatorArgs(args []string) (a incubatorArgs) {
func parseIncubatorArgs(args []string) (incubatorArgs, error) {
var ia incubatorArgs
var groups string
flags := flag.NewFlagSet("", flag.ExitOnError)
flags.IntVar(&a.uid, "uid", 0, "the uid of local-user")
flags.IntVar(&a.gid, "gid", 0, "the gid of local-user")
flags.StringVar(&a.groups, "groups", "", "comma-separated list of gids of local-user")
flags.StringVar(&a.localUser, "local-user", "", "the user to run as")
flags.StringVar(&a.remoteUser, "remote-user", "", "the remote user/tags")
flags.StringVar(&a.remoteIP, "remote-ip", "", "the remote Tailscale IP")
flags.StringVar(&a.ttyName, "tty-name", "", "the tty name (pts/3)")
flags.BoolVar(&a.hasTTY, "has-tty", false, "is the output attached to a tty")
flags.StringVar(&a.cmdName, "cmd", "", "the cmd to launch (ignored in sftp mode)")
flags.BoolVar(&a.isShell, "shell", false, "is launching a shell (with no cmds)")
flags.BoolVar(&a.isSFTP, "sftp", false, "run sftp server (cmd is ignored)")
flags.StringVar(&a.loginCmdPath, "login-cmd", "", "the path to `login` cmd")
flags.BoolVar(&a.debugTest, "debug-test", false, "should debug in test mode")
flags.StringVar(&ia.loginShell, "login-shell", "", "path to the user's preferred login shell")
flags.IntVar(&ia.uid, "uid", 0, "the uid of local-user")
flags.IntVar(&ia.gid, "gid", 0, "the gid of local-user")
flags.StringVar(&groups, "groups", "", "comma-separated list of gids of local-user")
flags.StringVar(&ia.localUser, "local-user", "", "the user to run as")
flags.StringVar(&ia.remoteUser, "remote-user", "", "the remote user/tags")
flags.StringVar(&ia.remoteIP, "remote-ip", "", "the remote Tailscale IP")
flags.StringVar(&ia.ttyName, "tty-name", "", "the tty name (pts/3)")
flags.BoolVar(&ia.hasTTY, "has-tty", false, "is the output attached to a tty")
flags.StringVar(&ia.cmd, "cmd", "", "the cmd to launch, including all arguments (ignored in sftp mode)")
flags.BoolVar(&ia.isShell, "shell", false, "is launching a shell (with no cmds)")
flags.BoolVar(&ia.isSFTP, "sftp", false, "run sftp server (cmd is ignored)")
flags.BoolVar(&ia.forceV1Behavior, "force-v1-behavior", false, "allow falling back to the su command if login is unavailable")
flags.BoolVar(&ia.debugTest, "debug-test", false, "should debug in test mode")
flags.Parse(args)
a.cmdArgs = flags.Args()
return a
for _, g := range strings.Split(groups, ",") {
gid, err := strconv.Atoi(g)
if err != nil {
return ia, fmt.Errorf("unable to parse group id %q: %w", g, err)
}
ia.gids = append(ia.gids, gid)
}
return ia, nil
}
// beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand.
// It is responsible for informing the system of a new login session for the user.
// This is sometimes necessary for mounting home directories and decrypting file
// systems.
// It is responsible for informing the system of a new login session for the
// user. This is sometimes necessary for mounting home directories and
// decrypting file systems.
//
// Tailscaled launches the incubator as the same user as it was
// launched as. The incubator then registers a new session with the
// OS, sets its UID and groups to the specified `--uid`, `--gid` and
// `--groups` and then launches the requested `--cmd`.
// Tailscaled launches the incubator as the same user as it was launched as.
func beIncubator(args []string) error {
// To defend against issues like https://golang.org/issue/1435,
// defensively lock our current goroutine's thread to the current
@@ -226,22 +232,25 @@ func beIncubator(args []string) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
ia := parseIncubatorArgs(args)
ia, err := parseIncubatorArgs(args)
if err != nil {
return err
}
if ia.isSFTP && ia.isShell {
return fmt.Errorf("--sftp and --shell are mutually exclusive")
}
logf := logger.Discard
dlogf := logger.Discard
if debugIncubator {
// We don't own stdout or stderr, so the only place we can log is syslog.
if sl, err := syslog.New(syslog.LOG_INFO|syslog.LOG_DAEMON, "tailscaled-ssh"); err == nil {
logf = log.New(sl, "", 0).Printf
dlogf = log.New(sl, "", 0).Printf
}
} else if ia.debugTest {
// In testing, we don't always have syslog, log to a temp file
// In testing, we don't always have syslog, so log to a temp file.
if logFile, err := os.OpenFile("/tmp/tailscalessh.log", os.O_APPEND|os.O_WRONLY, 0666); err == nil {
lf := log.New(logFile, "", 0)
logf = func(msg string, args ...any) {
dlogf = func(msg string, args ...any) {
lf.Printf(msg, args...)
logFile.Sync()
}
@@ -249,72 +258,233 @@ func beIncubator(args []string) error {
}
}
euid := os.Geteuid()
runningAsRoot := euid == 0
if runningAsRoot && ia.loginCmdPath != "" {
// Check if we can exec into the login command instead of trying to
// incubate ourselves.
if la := ia.loginArgs(); la != nil {
return unix.Exec(ia.loginCmdPath, la, os.Environ())
}
if !shouldAttemptLoginShell(dlogf, ia) {
dlogf("not attempting login shell")
return handleInProcess(dlogf, ia)
}
// Inform the system that we are about to log someone in.
// We can only do this if we are running as root.
// This is best effort to still allow running on machines where
// we don't support starting sessions, e.g. darwin.
sessionCloser, err := maybeStartLoginSession(logf, ia)
if err == nil && sessionCloser != nil {
defer sessionCloser()
}
var groupIDs []int
for _, g := range strings.Split(ia.groups, ",") {
gid, err := strconv.ParseInt(g, 10, 32)
if err != nil {
return err
}
groupIDs = append(groupIDs, int(gid))
}
if err := dropPrivileges(logf, ia.uid, ia.gid, groupIDs); err != nil {
// First try the login command
if err := tryExecLogin(dlogf, ia); err != nil {
return err
}
if ia.isSFTP {
logf("handling sftp")
// If we got here, we weren't able to use login (because tryExecLogin
// returned without replacing the running process), maybe we can use
// su.
if handled, err := trySU(dlogf, ia); handled {
return err
} else {
dlogf("not attempting su")
return handleInProcess(dlogf, ia)
}
}
server, err := sftp.NewServer(stdRWC{})
if err != nil {
return err
}
// TODO(https://github.com/pkg/sftp/pull/554): Revert the check for io.EOF,
// when sftp is patched to report clean termination.
if err := server.Serve(); err != nil && err != io.EOF {
return err
}
func handleInProcess(dlogf logger.Logf, ia incubatorArgs) error {
if ia.isSFTP {
return handleSFTPInProcess(dlogf, ia)
}
return handleSSHInProcess(dlogf, ia)
}
func handleSFTPInProcess(dlogf logger.Logf, ia incubatorArgs) error {
dlogf("handling sftp")
sessionCloser := maybeStartLoginSession(dlogf, ia)
if sessionCloser != nil {
defer sessionCloser()
}
if err := dropPrivileges(dlogf, ia); err != nil {
return err
}
return serveSFTP()
}
// beSFTP serves SFTP in-process.
func beSFTP(args []string) error {
return serveSFTP()
}
func serveSFTP() error {
server, err := sftp.NewServer(stdRWC{})
if err != nil {
return err
}
// TODO(https://github.com/pkg/sftp/pull/554): Revert the check for io.EOF,
// when sftp is patched to report clean termination.
if err := server.Serve(); err != nil && err != io.EOF {
return err
}
return nil
}
// shouldAttemptLoginShell decides whether we should attempt to get a full
// login shell with the login or su commands. We will attempt a login shell
// if all of the following conditions are met.
//
// - We are running as root
// - This is not an SELinuxEnforcing host
//
// The last condition exists because if we're running on a SELinux-enabled
// system, neiher login nor su will be able to set the correct context for the
// shell. So, we don't bother trying to run them and instead fall back to using
// the incubator to launch the shell.
// See http://github.com/tailscale/tailscale/issues/4908.
func shouldAttemptLoginShell(dlogf logger.Logf, ia incubatorArgs) bool {
if ia.forceV1Behavior && ia.isSFTP {
// v1 behavior did not run SFTP within a login shell.
dlogf("Forcing v1 behavior, won't use login shell for SFTP")
return false
}
return runningAsRoot() && !hostinfo.IsSELinuxEnforcing()
}
func runningAsRoot() bool {
euid := os.Geteuid()
return euid == 0
}
// tryExecLogin attempts to handle the ssh session by creating a full login
// shell using the login command. If it never tried, it returns nil. If it
// failed to do so, it returns an error.
//
// Creating a login shell in this way allows us to register the remote IP of
// the login session, trigger PAM authentication, and get the "remote" PAM
// profile.
//
// However, login is subject to some limitations.
//
// 1. login cannot be used to execute commands except on macOS.
// 2. On Linux and BSD, login requires a TTY to keep running.
//
// In these cases, tryExecLogin returns (false, nil) to indicate that processing
// should fall through to other methods, such as using the su command.
//
// Note that this uses unix.Exec to replace the current process, so in cases
// where we actually do run login, no subsequent Go code will execute.
func tryExecLogin(dlogf logger.Logf, ia incubatorArgs) error {
// Only the macOS version of the login command supports executing a
// command, all other versions only support launching a shell without
// taking any arguments.
if !ia.isShell && runtime.GOOS != "darwin" {
dlogf("won't use login because we're not in a shell or on macOS")
return nil
}
cmd := exec.Command(ia.cmdName, ia.cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if ia.hasTTY {
// If we were launched with a tty then we should
// mark that as the ctty of the child. However,
// as the ctty is being passed from the parent
// we set the child to foreground instead which
// also passes the ctty.
// However, we can not do this if never had a tty to
// begin with.
cmd.SysProcAttr = &syscall.SysProcAttr{
Foreground: true,
switch runtime.GOOS {
case "linux", "freebsd", "openbsd":
if !ia.hasTTY {
dlogf("can't use login because of missing TTY")
// We can only use the login command if a shell was requested with
// a TTY. If there is no TTY, login exits immediately, which
// breaks things like mosh and VSCode.
return nil
}
}
err = cmd.Run()
loginCmdPath, err := exec.LookPath("login")
if err != nil {
dlogf("failed to get login args: %s", err)
return nil
}
loginArgs := ia.loginArgs(loginCmdPath)
dlogf("logging in with %s %+v", loginCmdPath, loginArgs)
// replace the running process
return unix.Exec(loginCmdPath, loginArgs, os.Environ())
}
// trySU attempts to start a login shell using su. If su is available and
// supports the necessary arguments, this returns true, plus the result of
// executing su. Otherwise, it returns (false, nil).
//
// Creating a login shell in this way allows us to trigger PAM authentication
// and get the "login" PAM profile.
//
// Unlike login, su often does not require a TTY, so on Linux hosts that have
// an su command which accepts the right flags, we'll use su instead of login
// when no TTY is available.
func trySU(dlogf logger.Logf, ia incubatorArgs) (handled bool, err error) {
if ia.forceV1Behavior {
// v1 behavior did not use su.
dlogf("Forcing v1 behavior, won't use su")
return false, nil
}
su := findSU(dlogf, ia)
if su == "" {
return false, nil
}
sessionCloser := maybeStartLoginSession(dlogf, ia)
if sessionCloser != nil {
defer sessionCloser()
}
loginArgs := []string{"-l", ia.localUser}
if ia.cmd != "" {
// Note - unlike the login command, su allows using both -l and -c.
loginArgs = append(loginArgs, "-c", ia.cmd)
}
dlogf("logging in with %s %q", su, loginArgs)
cmd := newCommand(ia.hasTTY, su, loginArgs)
return true, cmd.Run()
}
// findSU attempts to find an su command which supports the -l and -c flags.
// This actually calls the su command, which can cause side effects like
// triggering pam_mkhomedir. If a suitable su is not available, this returns
// "".
func findSU(dlogf logger.Logf, ia incubatorArgs) string {
// Currently, we only support falling back to su on Linux. This
// potentially could work on BSDs as well, but requires testing.
if runtime.GOOS != "linux" {
return ""
}
// gokrazy doesn't include su. And, if someone installs a breakglass/
// debugging package on gokrazy, we don't want to use its su.
if distro.Get() == distro.Gokrazy {
return ""
}
su, err := exec.LookPath("su")
if err != nil {
dlogf("can't find su command: %v", err)
return ""
}
// First try to execute su -l <user> -c true to make sure su supports the
// necessary arguments.
err = exec.Command(su, "-l", ia.localUser, "-c", "true").Run()
if err != nil {
dlogf("su check failed: %s", err)
return ""
}
return su
}
// handleSSHInProcess is a last resort if we couldn't use login or su. It
// registers a new session with the OS, sets its UID, GID and groups to the
// specified values, and then launches the requested `--cmd` in the user's
// login shell.
func handleSSHInProcess(dlogf logger.Logf, ia incubatorArgs) error {
sessionCloser := maybeStartLoginSession(dlogf, ia)
if sessionCloser != nil {
defer sessionCloser()
}
if err := dropPrivileges(dlogf, ia); err != nil {
return err
}
args := shellArgs(ia.isShell, ia.cmd)
dlogf("running %s %q", ia.loginShell, args)
cmd := newCommand(ia.hasTTY, ia.loginShell, args)
err := cmd.Run()
if ee, ok := err.(*exec.ExitError); ok {
ps := ee.ProcessState
code := ps.ExitCode()
@@ -330,6 +500,26 @@ func beIncubator(args []string) error {
return err
}
func newCommand(hasTTY bool, cmdPath string, cmdArgs []string) *exec.Cmd {
cmd := exec.Command(cmdPath, cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if hasTTY {
// If we were launched with a tty then we should mark that as the ctty
// of the child. However, as the ctty is being passed from the parent
// we set the child to foreground instead which also passes the ctty.
// However, we can not do this if never had a tty to begin with.
cmd.SysProcAttr = &syscall.SysProcAttr{
Foreground: true,
}
}
return cmd
}
const (
// This controls whether we assert that our privileges were dropped
// using geteuid/getegid; it's a const and not an envknob because the
@@ -344,19 +534,26 @@ const (
assertPrivilegesWereDroppedByAttemptingToUnDrop = false
)
// dropPrivileges contains all the logic for dropping privileges to a different
// dropPrivileges calls doDropPrivileges with uid, gid, and gids from the given
// incubatorArgs.
func dropPrivileges(dlogf logger.Logf, ia incubatorArgs) error {
return doDropPrivileges(dlogf, ia.uid, ia.gid, ia.gids)
}
// doDropPrivileges contains all the logic for dropping privileges to a different
// UID, GID, and set of supplementary groups. This function is
// security-sensitive and ordering-dependent; please be very cautious if/when
// refactoring.
//
// WARNING: if you change this function, you *MUST* run the TestDropPrivileges
// WARNING: if you change this function, you *MUST* run the TestDoDropPrivileges
// test in this package as root on at least Linux, FreeBSD and Darwin. This can
// be done by running:
//
// go test -c ./ssh/tailssh/ && sudo ./tailssh.test -test.v -test.run TestDropPrivileges
func dropPrivileges(logf logger.Logf, wantUid, wantGid int, supplementaryGroups []int) error {
// go test -c ./ssh/tailssh/ && sudo ./tailssh.test -test.v -test.run TestDoDropPrivileges
func doDropPrivileges(dlogf logger.Logf, wantUid, wantGid int, supplementaryGroups []int) error {
dlogf("dropping privileges")
fatalf := func(format string, args ...any) {
logf("[unexpected] error dropping privileges: "+format, args...)
dlogf("[unexpected] error dropping privileges: "+format, args...)
os.Exit(1)
}
@@ -448,7 +645,11 @@ func dropPrivileges(logf logger.Logf, wantUid, wantGid int, supplementaryGroups
//
// It sets ss.cmd, stdin, stdout, and stderr.
func (ss *sshSession) launchProcess() error {
ss.cmd = ss.newIncubatorCommand()
var err error
ss.cmd, err = ss.newIncubatorCommand(ss.logf)
if err != nil {
return err
}
cmd := ss.cmd
homeDir := ss.conn.localUser.HomeDir
@@ -749,18 +950,11 @@ func fileExists(path string) bool {
}
// loginArgs returns the arguments to use to exec the login binary.
// It returns nil if the login binary should not be used.
// The login binary is only used:
// - on darwin, if the client is requesting a shell or a command.
// - on linux and BSD, if the client is requesting a shell with a TTY.
func (ia *incubatorArgs) loginArgs() []string {
if ia.isSFTP {
return nil
}
func (ia *incubatorArgs) loginArgs(loginCmdPath string) []string {
switch runtime.GOOS {
case "darwin":
args := []string{
ia.loginCmdPath,
loginCmdPath,
"-f", // already authenticated
// login typically discards the previous environment, but we want to
@@ -773,39 +967,35 @@ func (ia *incubatorArgs) loginArgs() []string {
if !ia.hasTTY {
args[2] = "-pq" // -q is "quiet" which suppresses the login banner
}
if ia.cmdName != "" {
args = append(args, ia.cmdName)
args = append(args, ia.cmdArgs...)
if ia.cmd != "" {
args = append(args, ia.loginShell, "-c", ia.cmd)
}
return args
case "linux":
if !ia.isShell || !ia.hasTTY {
// We can only use login command if a shell was requested with a TTY. If
// there is no TTY, login exits immediately, which breaks things likes
// mosh and VSCode.
return nil
}
if distro.Get() == distro.Arch && !fileExists("/etc/pam.d/remote") {
// See https://github.com/tailscale/tailscale/issues/4924
//
// Arch uses a different login binary that makes the -h flag set the PAM
// service to "remote". So if they don't have that configured, don't
// pass -h.
return []string{ia.loginCmdPath, "-f", ia.localUser, "-p"}
return []string{loginCmdPath, "-f", ia.localUser, "-p"}
}
return []string{ia.loginCmdPath, "-f", ia.localUser, "-h", ia.remoteIP, "-p"}
return []string{loginCmdPath, "-f", ia.localUser, "-h", ia.remoteIP, "-p"}
case "freebsd", "openbsd":
if !ia.isShell || !ia.hasTTY {
// We can only use login command if a shell was requested with a TTY. If
// there is no TTY, login exits immediately, which breaks things likes
// mosh and VSCode.
return nil
}
return []string{ia.loginCmdPath, "-fp", "-h", ia.remoteIP, ia.localUser}
return []string{loginCmdPath, "-fp", "-h", ia.remoteIP, ia.localUser}
}
panic("unimplemented")
}
func shellArgs(isShell bool, cmd string) []string {
if isShell {
return []string{"-l"}
} else {
return []string{"-c", cmd}
}
}
func setGroups(groupIDs []int) error {
if runtime.GOOS == "darwin" && len(groupIDs) > 16 {
// darwin returns "invalid argument" if more than 16 groups are passed to syscall.Setgroups

View File

@@ -146,11 +146,11 @@ func releaseSession(sessionID string) error {
}
// maybeStartLoginSessionLinux is the linux implementation of maybeStartLoginSession.
func maybeStartLoginSessionLinux(logf logger.Logf, ia incubatorArgs) (func() error, error) {
func maybeStartLoginSessionLinux(dlogf logger.Logf, ia incubatorArgs) func() error {
if os.Geteuid() != 0 {
return nil, nil
return nil
}
logf("starting session for user %d", ia.uid)
dlogf("starting session for user %d", ia.uid)
// The only way we can actually start a new session is if we are
// running outside one and are root, which is typically the case
// for systemd managed tailscaled.
@@ -160,14 +160,14 @@ func maybeStartLoginSessionLinux(logf logger.Logf, ia incubatorArgs) (func() err
// We can look at the DBus GetSessionByPID API.
// https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html
// For now best effort is fine.
logf("ssh: failed to CreateSession for user %q (%d) %v", ia.localUser, ia.uid, err)
return nil, nil
dlogf("ssh: failed to CreateSession for user %q (%d) %v", ia.localUser, ia.uid, err)
return nil
}
os.Setenv("DBUS_SESSION_BUS_ADDRESS", fmt.Sprintf("unix:path=%v/bus", resp.runtimePath))
if !resp.existing {
return func() error {
return releaseSession(resp.sessionID)
}, nil
}
}
return nil, nil
return nil
}

View File

@@ -23,7 +23,7 @@ import (
"tailscale.com/types/logger"
)
func TestDropPrivileges(t *testing.T) {
func TestDoDropPrivileges(t *testing.T) {
type SubprocInput struct {
UID int
GID int
@@ -49,7 +49,7 @@ func TestDropPrivileges(t *testing.T) {
f := os.NewFile(3, "out.json")
// We're in our subprocess; actually drop privileges now.
dropPrivileges(t.Logf, input.UID, input.GID, input.AdditionalGroups)
doDropPrivileges(t.Logf, input.UID, input.GID, input.AdditionalGroups)
additional, _ := syscall.Getgroups()

View File

@@ -8,6 +8,7 @@ package tailssh
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
@@ -28,6 +29,7 @@ import (
"testing"
"time"
"github.com/bramvdbogaerde/go-scp"
"github.com/google/go-cmp/cmp"
"github.com/pkg/sftp"
gossh "github.com/tailscale/golang-x-crypto/ssh"
@@ -36,6 +38,7 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/util/set"
)
// This file contains integration tests of the SSH functionality. These tests
@@ -58,7 +61,7 @@ func TestMain(m *testing.M) {
file.Close()
// Tail our log file.
cmd := exec.Command("tail", "-f", "/tmp/tailscalessh.log")
cmd := exec.Command("tail", "-F", "/tmp/tailscalessh.log")
r, err := cmd.StdoutPipe()
if err != nil {
@@ -77,6 +80,12 @@ func TestMain(m *testing.M) {
if err != nil {
return
}
defer func() {
// tail -f has a default sleep interval of 1 second, so it takes a
// moment for it to finish reading our log file after we've terminated.
// So, wait a bit to let it catch up.
time.Sleep(2 * time.Second)
}()
m.Run()
}
@@ -93,20 +102,40 @@ func TestIntegrationSSH(t *testing.T) {
}
tests := []struct {
cmd string
want []string
cmd string
want []string
forceV1Behavior bool
skip bool
}{
{
cmd: "id",
want: []string{"testuser", "groupone", "grouptwo"},
cmd: "id",
want: []string{"testuser", "groupone", "grouptwo"},
forceV1Behavior: false,
},
{
cmd: "pwd",
want: []string{homeDir},
cmd: "id",
want: []string{"testuser", "groupone", "grouptwo"},
forceV1Behavior: true,
},
{
cmd: "pwd",
want: []string{homeDir},
skip: !fallbackToSUAvailable(),
forceV1Behavior: false,
},
{
cmd: "echo 'hello'",
want: []string{"hello"},
skip: !fallbackToSUAvailable(),
forceV1Behavior: false,
},
}
for _, test := range tests {
if test.skip {
continue
}
// run every test both without and with a shell
for _, shell := range []bool{false, true} {
shellQualifier := "no_shell"
@@ -114,8 +143,13 @@ func TestIntegrationSSH(t *testing.T) {
shellQualifier = "shell"
}
t.Run(fmt.Sprintf("%s_%s", test.cmd, shellQualifier), func(t *testing.T) {
s := testSession(t)
versionQualifier := "v2"
if test.forceV1Behavior {
versionQualifier = "v1"
}
t.Run(fmt.Sprintf("%s_%s_%s", test.cmd, shellQualifier, versionQualifier), func(t *testing.T) {
s := testSession(t, test.forceV1Behavior)
if shell {
err := s.RequestPty("xterm", 40, 80, ssh.TerminalModes{
@@ -123,12 +157,20 @@ func TestIntegrationSSH(t *testing.T) {
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
})
if err != nil {
t.Fatalf("unable to request PTY: %s", err)
}
err = s.Shell()
if err != nil {
t.Fatalf("unable to request shell: %s", err)
}
// Read the shell prompt
s.read()
}
got := s.run(t, test.cmd)
got := s.run(t, test.cmd, shell)
for _, want := range test.want {
if !strings.Contains(got, want) {
t.Errorf("%q does not contain %q", got, want)
@@ -145,48 +187,133 @@ func TestIntegrationSFTP(t *testing.T) {
debugTest.Store(false)
})
filePath := "/tmp/sftptest.dat"
wantText := "hello world"
for _, forceV1Behavior := range []bool{false, true} {
name := "v2"
if forceV1Behavior {
name = "v1"
}
t.Run(name, func(t *testing.T) {
filePath := "/home/testuser/sftptest.dat"
if forceV1Behavior || !fallbackToSUAvailable() {
filePath = "/tmp/sftptest.dat"
}
wantText := "hello world"
cl := testClient(t)
scl, err := sftp.NewClient(cl)
if err != nil {
t.Fatalf("can't get sftp client: %s", err)
cl := testClient(t, forceV1Behavior)
scl, err := sftp.NewClient(cl)
if err != nil {
t.Fatalf("can't get sftp client: %s", err)
}
file, err := scl.Create(filePath)
if err != nil {
t.Fatalf("can't create file: %s", err)
}
_, err = file.Write([]byte(wantText))
if err != nil {
t.Fatalf("can't write to file: %s", err)
}
err = file.Close()
if err != nil {
t.Fatalf("can't close file: %s", err)
}
file, err = scl.OpenFile(filePath, os.O_RDONLY)
if err != nil {
t.Fatalf("can't open file: %s", err)
}
defer file.Close()
gotText, err := io.ReadAll(file)
if err != nil {
t.Fatalf("can't read file: %s", err)
}
if diff := cmp.Diff(string(gotText), wantText); diff != "" {
t.Fatalf("unexpected file contents (-got +want):\n%s", diff)
}
s := testSessionFor(t, cl)
got := s.run(t, "ls -l "+filePath, false)
if !strings.Contains(got, "testuser") {
t.Fatalf("unexpected file owner user: %s", got)
} else if !strings.Contains(got, "testuser") {
t.Fatalf("unexpected file owner group: %s", got)
}
})
}
}
func TestIntegrationSCP(t *testing.T) {
debugTest.Store(true)
t.Cleanup(func() {
debugTest.Store(false)
})
for _, forceV1Behavior := range []bool{false, true} {
name := "v2"
if forceV1Behavior {
name = "v1"
}
t.Run(name, func(t *testing.T) {
filePath := "/home/testuser/scptest.dat"
if !fallbackToSUAvailable() {
filePath = "/tmp/scptest.dat"
}
wantText := "hello world"
cl := testClient(t, forceV1Behavior)
scl, err := scp.NewClientBySSH(cl)
if err != nil {
t.Fatalf("can't get sftp client: %s", err)
}
err = scl.Copy(context.Background(), strings.NewReader(wantText), filePath, "0644", int64(len(wantText)))
if err != nil {
t.Fatalf("can't create file: %s", err)
}
outfile, err := os.CreateTemp("", "")
if err != nil {
t.Fatalf("can't create temp file: %s", err)
}
err = scl.CopyFromRemote(context.Background(), outfile, filePath)
if err != nil {
t.Fatalf("can't copy file from remote: %s", err)
}
outfile.Close()
gotText, err := os.ReadFile(outfile.Name())
if err != nil {
t.Fatalf("can't read file: %s", err)
}
if diff := cmp.Diff(string(gotText), wantText); diff != "" {
t.Fatalf("unexpected file contents (-got +want):\n%s", diff)
}
s := testSessionFor(t, cl)
got := s.run(t, "ls -l "+filePath, false)
if !strings.Contains(got, "testuser") {
t.Fatalf("unexpected file owner user: %s", got)
} else if !strings.Contains(got, "testuser") {
t.Fatalf("unexpected file owner group: %s", got)
}
})
}
}
func fallbackToSUAvailable() bool {
if runtime.GOOS != "linux" {
return false
}
file, err := scl.Create(filePath)
_, err := exec.LookPath("su")
if err != nil {
t.Fatalf("can't create file: %s", err)
}
_, err = file.Write([]byte(wantText))
if err != nil {
t.Fatalf("can't write to file: %s", err)
}
err = file.Close()
if err != nil {
t.Fatalf("can't close file: %s", err)
return false
}
file, err = scl.OpenFile(filePath, os.O_RDONLY)
if err != nil {
t.Fatalf("can't open file: %s", err)
}
defer file.Close()
gotText, err := io.ReadAll(file)
if err != nil {
t.Fatalf("can't read file: %s", err)
}
if diff := cmp.Diff(string(gotText), wantText); diff != "" {
t.Fatalf("unexpected file contents (-got +want):\n%s", diff)
}
s := testSessionFor(t, cl)
got := s.run(t, "ls -l "+filePath)
if !strings.Contains(got, "testuser") {
t.Fatalf("unexpected file owner user: %s", got)
} else if !strings.Contains(got, "testuser") {
t.Fatalf("unexpected file owner group: %s", got)
}
// Some operating systems like Fedora seem to require login to be present
// in order for su to work.
_, err = exec.LookPath("login")
return err == nil
}
type session struct {
@@ -197,14 +324,25 @@ type session struct {
stderr io.ReadCloser
}
func (s *session) run(t *testing.T, cmdString string) string {
func (s *session) run(t *testing.T, cmdString string, shell bool) string {
t.Helper()
err := s.Start(cmdString)
if err != nil {
t.Fatalf("unable to start command: %s", err)
if shell {
_, err := s.stdin.Write([]byte(fmt.Sprintf("%s\n", cmdString)))
if err != nil {
t.Fatalf("unable to send command to shell: %s", err)
}
} else {
err := s.Start(cmdString)
if err != nil {
t.Fatalf("unable to start command: %s", err)
}
}
return s.read()
}
func (s *session) read() string {
ch := make(chan []byte)
go func() {
for {
@@ -228,7 +366,7 @@ readLoop:
select {
case b := <-ch:
_got = append(_got, b...)
case <-time.After(25 * time.Millisecond):
case <-time.After(1 * time.Second):
break readLoop
}
}
@@ -236,12 +374,12 @@ readLoop:
return string(_got)
}
func testClient(t *testing.T) *ssh.Client {
func testClient(t *testing.T, forceV1Behavior bool) *ssh.Client {
t.Helper()
username := "testuser"
srv := &server{
lb: &testBackend{localUser: username},
lb: &testBackend{localUser: username, forceV1Behavior: forceV1Behavior},
logf: log.Printf,
tailscaledPath: os.Getenv("TAILSCALED_PATH"),
timeNow: time.Now,
@@ -271,8 +409,8 @@ func testClient(t *testing.T) *ssh.Client {
return cl
}
func testSession(t *testing.T) *session {
cl := testClient(t)
func testSession(t *testing.T, forceV1Behavior bool) *session {
cl := testClient(t, forceV1Behavior)
return testSessionFor(t, cl)
}
@@ -299,7 +437,8 @@ func testSessionFor(t *testing.T, cl *ssh.Client) *session {
// testBackend implements ipnLocalBackend
type testBackend struct {
localUser string
localUser string
forceV1Behavior bool
}
func (tb *testBackend) GetSSH_HostKeys() ([]gossh.Signer, error) {
@@ -339,16 +478,21 @@ func (tb *testBackend) ShouldRunSSH() bool {
}
func (tb *testBackend) NetMap() *netmap.NetworkMap {
capMap := make(set.Set[tailcfg.NodeCapability])
if tb.forceV1Behavior {
capMap[tailcfg.NodeAttrSSHBehaviorV1] = struct{}{}
}
return &netmap.NetworkMap{
SSHPolicy: &tailcfg.SSHPolicy{
Rules: []*tailcfg.SSHRule{
&tailcfg.SSHRule{
{
Principals: []*tailcfg.SSHPrincipal{{Any: true}},
Action: &tailcfg.SSHAction{Accept: true},
SSHUsers: map[string]string{"*": tb.localUser},
},
},
},
AllCaps: capMap,
}
}

View File

@@ -1,18 +1,51 @@
ARG BASE
FROM ${BASE}
RUN echo "Install openssh, needed for scp."
RUN apt-get update -y && apt-get install -y openssh-client
RUN groupadd -g 10000 groupone
RUN groupadd -g 10001 grouptwo
RUN useradd -g 10000 -G 10001 -u 10002 -m testuser
COPY . .
# Note - we do not create the user's home directory, pam_mkhomedir will do that
# for us, and we want to test that PAM gets triggered by Tailscale SSH.
RUN useradd -g 10000 -G 10001 -u 10002 testuser
# First run tests normally.
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.run TestIntegration
RUN echo "Set up pam_mkhomedir."
RUN sed -i -e 's/Default: no/Default: yes/g' /usr/share/pam-configs/mkhomedir || echo "might not be ubuntu"
RUN cat /usr/share/pam-configs/mkhomedir
RUN pam-auth-update --enable mkhomedir
# Then remove the login command and make sure tests still pass.
RUN rm `which login`
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.run TestIntegration
COPY tailscaled .
COPY tailssh.test .
# Then run tests as non-root user testuser.
RUN chmod 755 tailscaled
RUN echo "First run tests normally."
RUN rm -Rf /home/testuser
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSFTP
RUN rm -Rf /home/testuser
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSCP
RUN rm -Rf /home/testuser
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSSH
RUN echo "Then run tests as non-root user testuser and make sure tests still pass."
RUN chown testuser:groupone /tmp/tailscalessh.log
RUN TAILSCALED_PATH=`pwd`tailscaled su -m testuser -c "./tailssh.test -test.run TestIntegration"
RUN TAILSCALED_PATH=`pwd`tailscaled su -m testuser -c "./tailssh.test -test.v -test.run TestIntegration TestDoDropPrivileges"
RUN echo "Then remove the login command and make sure tests still pass."
RUN chown root:root /tmp/tailscalessh.log
RUN rm `which login`
RUN rm -Rf /home/testuser
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSFTP
RUN rm -Rf /home/testuser
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSCP
RUN rm -Rf /home/testuser
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSSH
RUN echo "Then remove the su command and make sure tests still pass."
RUN chown root:root /tmp/tailscalessh.log
RUN rm `which su`
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegration
RUN echo "Test doDropPrivileges"
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestDoDropPrivileges

View File

@@ -136,7 +136,8 @@ type CapabilityVersion int
// - 93: 2024-05-06: added support for stateful firewalling.
// - 94: 2024-05-06: Client understands Node.IsJailed.
// - 95: 2024-05-06: Client uses NodeAttrUserDialUseRoutes to change DNS dialing behavior.
const CurrentCapabilityVersion CapabilityVersion = 95
// - 96: 2024-05-29: Client understands NodeAttrSSHBehaviorV1
const CurrentCapabilityVersion CapabilityVersion = 96
type StableID string
@@ -608,10 +609,11 @@ func isAlpha(b byte) bool {
//
// We might relax these rules later.
func CheckTag(tag string) error {
if !strings.HasPrefix(tag, "tag:") {
var ok bool
tag, ok = strings.CutPrefix(tag, "tag:")
if !ok {
return errors.New("tags must start with 'tag:'")
}
tag = tag[4:]
if tag == "" {
return errors.New("tag names must not be empty")
}
@@ -1082,7 +1084,7 @@ type RegisterResponseAuth struct {
// At most one of Oauth2Token or AuthKey is set.
Oauth2Token *Oauth2Token `json:",omitempty"`
Oauth2Token *Oauth2Token `json:",omitempty"` // used by pre-1.66 Android only
AuthKey string `json:",omitempty"`
}
@@ -2274,6 +2276,10 @@ const (
// depending on the destination address and the configured routes. When present, it also makes
// the DNS forwarder use UserDial instead of SystemDial when dialing resolvers.
NodeAttrUserDialUseRoutes NodeCapability = "user-dial-routes"
// NodeAttrSSHBehaviorV1 forces SSH to use the V1 behavior (no su, run SFTP in-process)
// Added 2024-05-29 in Tailscale version 1.68.
NodeAttrSSHBehaviorV1 NodeCapability = "ssh-behavior-v1"
)
// SetDNSRequest is a request to add a DNS record.

View File

@@ -859,8 +859,33 @@ func TestDeps(t *testing.T) {
BadDeps: map[string]string{
// Make sure we don't again accidentally bring in a dependency on
// drive or its transitive dependencies
"testing": "do not use testing package in production code",
"tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
},
}.Check(t)
}
func TestCheckTag(t *testing.T) {
tests := []struct {
name string
tag string
want bool
}{
{"empty", "", false},
{"good", "tag:foo", true},
{"bad", "tag:", false},
{"no_leading_num", "tag:1foo", false},
{"no_punctuation", "tag:foa@bar", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckTag(tt.tag)
if err == nil && !tt.want {
t.Errorf("got nil; want error")
} else if err != nil && tt.want {
t.Errorf("got %v; want nil", err)
}
})
}
}

View File

@@ -8,6 +8,7 @@ import (
"crypto/ed25519"
"errors"
"fmt"
"strings"
"github.com/fxamacker/cbor/v2"
"github.com/hdevalence/ed25519consensus"
@@ -96,6 +97,41 @@ type NodeKeySignature struct {
WrappingPubkey []byte `cbor:"6,keyasint,omitempty"`
}
// String returns a human-readable representation of the NodeKeySignature,
// making it easy to see nested signatures.
func (s NodeKeySignature) String() string {
var b strings.Builder
var addToBuf func(NodeKeySignature, int)
addToBuf = func(sig NodeKeySignature, depth int) {
indent := strings.Repeat(" ", depth)
b.WriteString(indent + "SigKind: " + sig.SigKind.String() + "\n")
if len(sig.Pubkey) > 0 {
var pubKey string
var np key.NodePublic
if err := np.UnmarshalBinary(sig.Pubkey); err != nil {
pubKey = fmt.Sprintf("<error: %s>", err)
} else {
pubKey = np.ShortString()
}
b.WriteString(indent + "Pubkey: " + pubKey + "\n")
}
if len(sig.KeyID) > 0 {
keyID := key.NLPublicFromEd25519Unsafe(sig.KeyID).CLIString()
b.WriteString(indent + "KeyID: " + keyID + "\n")
}
if len(sig.WrappingPubkey) > 0 {
pubKey := key.NLPublicFromEd25519Unsafe(sig.WrappingPubkey).CLIString()
b.WriteString(indent + "WrappingPubkey: " + pubKey + "\n")
}
if sig.Nested != nil {
b.WriteString(indent + "Nested:\n")
addToBuf(*sig.Nested, depth+1)
}
}
addToBuf(s, 0)
return strings.TrimSpace(b.String())
}
// UnverifiedWrappingPublic returns the public key which must sign a
// signature which embeds this one, if any.
//
@@ -268,3 +304,78 @@ func (s *NodeKeySignature) verifySignature(nodeKey key.NodePublic, verificationK
return fmt.Errorf("unhandled signature type: %v", s.SigKind)
}
}
// RotationDetails holds additional information about a nodeKeySignature
// of kind SigRotation.
type RotationDetails struct {
// PrevNodeKeys is a list of node keys which have been rotated out.
PrevNodeKeys []key.NodePublic
// WrappingPubkey is the public key which has been authorized to sign
// this rotating signature.
WrappingPubkey []byte
}
// rotationDetails returns the RotationDetails for a SigRotation signature.
func (s *NodeKeySignature) rotationDetails() (*RotationDetails, error) {
if s.SigKind != SigRotation {
return nil, nil
}
sri := &RotationDetails{}
nested := s.Nested
for nested != nil {
if len(nested.Pubkey) > 0 {
var nestedPub key.NodePublic
if err := nestedPub.UnmarshalBinary(nested.Pubkey); err != nil {
return nil, fmt.Errorf("nested pubkey: %v", err)
}
sri.PrevNodeKeys = append(sri.PrevNodeKeys, nestedPub)
}
if nested.SigKind != SigRotation {
break
}
nested = nested.Nested
}
sri.WrappingPubkey = nested.WrappingPubkey
return sri, nil
}
// ResignNKS re-signs a node-key signature for a new node-key.
//
// This only matters on network-locked tailnets, because node-key signatures are
// how other nodes know that a node-key is authentic. When the node-key is
// rotated then the existing signature becomes invalid, so this function is
// responsible for generating a new wrapping signature to certify the new node-key.
//
// The signature itself is a SigRotation signature, which embeds the old signature
// and certifies the new node-key as a replacement for the old by signing the new
// signature with RotationPubkey (which is the node's own network-lock key).
func ResignNKS(priv key.NLPrivate, nodeKey key.NodePublic, oldNKS tkatype.MarshaledSignature) (tkatype.MarshaledSignature, error) {
var oldSig NodeKeySignature
if err := oldSig.Unserialize(oldNKS); err != nil {
return nil, fmt.Errorf("decoding NKS: %w", err)
}
nk, err := nodeKey.MarshalBinary()
if err != nil {
return nil, fmt.Errorf("marshalling node-key: %w", err)
}
if bytes.Equal(nk, oldSig.Pubkey) {
// The old signature is valid for the node-key we are using, so just
// use it verbatim.
return oldNKS, nil
}
newSig := NodeKeySignature{
SigKind: SigRotation,
Pubkey: nk,
Nested: &oldSig,
}
if newSig.Signature, err = priv.SignNKS(newSig.SigHash()); err != nil {
return nil, fmt.Errorf("signing NKS: %w", err)
}
return newSig.Serialize(), nil
}

View File

@@ -5,6 +5,7 @@ package tka
import (
"crypto/ed25519"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
@@ -298,3 +299,143 @@ func TestSigSerializeUnserialize(t *testing.T) {
t.Errorf("unmarshalled version differs (-want, +got):\n%s", diff)
}
}
func TestNodeKeySignatureRotationDetails(t *testing.T) {
// Trusted network lock key
pub, priv := testingKey25519(t, 1)
k := Key{Kind: Key25519, Public: pub, Votes: 2}
// 'credential' key (the one being delegated to)
cPub, cPriv := testingKey25519(t, 2)
n1, n2, n3 := key.NewNode(), key.NewNode(), key.NewNode()
n1pub, _ := n1.Public().MarshalBinary()
n2pub, _ := n2.Public().MarshalBinary()
n3pub, _ := n3.Public().MarshalBinary()
tests := []struct {
name string
nodeKey key.NodePublic
sigFn func() NodeKeySignature
want *RotationDetails
}{
{
name: "SigDirect",
nodeKey: n1.Public(),
sigFn: func() NodeKeySignature {
s := NodeKeySignature{
SigKind: SigDirect,
KeyID: pub,
Pubkey: n1pub,
}
sigHash := s.SigHash()
s.Signature = ed25519.Sign(priv, sigHash[:])
return s
},
want: nil,
},
{
name: "SigWrappedCredential",
nodeKey: n1.Public(),
sigFn: func() NodeKeySignature {
nestedSig := NodeKeySignature{
SigKind: SigCredential,
KeyID: pub,
WrappingPubkey: cPub,
}
sigHash := nestedSig.SigHash()
nestedSig.Signature = ed25519.Sign(priv, sigHash[:])
sig := NodeKeySignature{
SigKind: SigRotation,
Pubkey: n1pub,
Nested: &nestedSig,
}
sigHash = sig.SigHash()
sig.Signature = ed25519.Sign(cPriv, sigHash[:])
return sig
},
want: &RotationDetails{
WrappingPubkey: cPub,
},
},
{
name: "SigRotation",
nodeKey: n2.Public(),
sigFn: func() NodeKeySignature {
nestedSig := NodeKeySignature{
SigKind: SigDirect,
Pubkey: n1pub,
KeyID: pub,
WrappingPubkey: cPub,
}
sigHash := nestedSig.SigHash()
nestedSig.Signature = ed25519.Sign(priv, sigHash[:])
sig := NodeKeySignature{
SigKind: SigRotation,
Pubkey: n2pub,
Nested: &nestedSig,
}
sigHash = sig.SigHash()
sig.Signature = ed25519.Sign(cPriv, sigHash[:])
return sig
},
want: &RotationDetails{
WrappingPubkey: cPub,
PrevNodeKeys: []key.NodePublic{n1.Public()},
},
},
{
name: "SigRotationNestedTwice",
nodeKey: n3.Public(),
sigFn: func() NodeKeySignature {
initialSig := NodeKeySignature{
SigKind: SigDirect,
Pubkey: n1pub,
KeyID: pub,
WrappingPubkey: cPub,
}
sigHash := initialSig.SigHash()
initialSig.Signature = ed25519.Sign(priv, sigHash[:])
prevRotation := NodeKeySignature{
SigKind: SigRotation,
Pubkey: n2pub,
Nested: &initialSig,
}
sigHash = prevRotation.SigHash()
prevRotation.Signature = ed25519.Sign(cPriv, sigHash[:])
sig := NodeKeySignature{
SigKind: SigRotation,
Pubkey: n3pub,
Nested: &prevRotation,
}
sigHash = sig.SigHash()
sig.Signature = ed25519.Sign(cPriv, sigHash[:])
return sig
},
want: &RotationDetails{
WrappingPubkey: cPub,
PrevNodeKeys: []key.NodePublic{n2.Public(), n1.Public()},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sig := tt.sigFn()
if err := sig.verifySignature(tt.nodeKey, k); err != nil {
t.Fatalf("verifySignature(node) failed: %v", err)
}
got, err := sig.rotationDetails()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("rotationDetails() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -668,25 +668,36 @@ func (a *Authority) Inform(storage Chonk, updates []AUM) error {
// NodeKeyAuthorized checks if the provided nodeKeySignature authorizes
// the given node key.
func (a *Authority) NodeKeyAuthorized(nodeKey key.NodePublic, nodeKeySignature tkatype.MarshaledSignature) error {
_, err := a.NodeKeyAuthorizedWithDetails(nodeKey, nodeKeySignature)
return err
}
// NodeKeyAuthorized checks if the provided nodeKeySignature authorizes
// the given node key, and returns RotationDetails if the signature is
// a valid rotation signature.
func (a *Authority) NodeKeyAuthorizedWithDetails(nodeKey key.NodePublic, nodeKeySignature tkatype.MarshaledSignature) (*RotationDetails, error) {
var decoded NodeKeySignature
if err := decoded.Unserialize(nodeKeySignature); err != nil {
return fmt.Errorf("unserialize: %v", err)
return nil, fmt.Errorf("unserialize: %v", err)
}
if decoded.SigKind == SigCredential {
return errors.New("credential signatures cannot authorize nodes on their own")
return nil, errors.New("credential signatures cannot authorize nodes on their own")
}
kID, err := decoded.authorizingKeyID()
if err != nil {
return err
return nil, err
}
key, err := a.state.GetKey(kID)
if err != nil {
return fmt.Errorf("key: %v", err)
return nil, fmt.Errorf("key: %v", err)
}
return decoded.verifySignature(nodeKey, key)
if err := decoded.verifySignature(nodeKey, key); err != nil {
return nil, err
}
return decoded.rotationDetails()
}
// KeyTrusted returns true if the given keyID is trusted by the tailnet

View File

@@ -4,6 +4,7 @@
package main
import (
"cmp"
"fmt"
"runtime"
"strings"
@@ -32,10 +33,10 @@ func autoflagsForTest(argv []string, env *Environment, goroot, nativeGOOS, nativ
subcommand = ""
cc = "cc"
targetOS = env.Get("GOOS", nativeGOOS)
targetArch = env.Get("GOARCH", nativeGOARCH)
targetOS = cmp.Or(env.Get("GOOS", ""), nativeGOOS)
targetArch = cmp.Or(env.Get("GOARCH", ""), nativeGOARCH)
buildFlags = []string{"-trimpath"}
cgoCflags = []string{"-O3", "-std=gnu11"}
cgoCflags = []string{"-O3", "-std=gnu11", "-g"}
cgoLdflags []string
ldflags []string
tags = []string{"tailscale_go"}

View File

@@ -41,7 +41,7 @@ func TestAutoflags(t *testing.T) {
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -67,7 +67,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -96,7 +96,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=0 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=riscv64 (was riscv64)
@@ -125,7 +125,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=0 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -151,7 +151,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -181,7 +181,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=0 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -210,7 +210,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=0 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -240,7 +240,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was 1)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -266,7 +266,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "arm64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=arm64 (was <nil>)
@@ -275,6 +275,64 @@ GOMIPS=softfloat (was <nil>)
GOOS=darwin (was <nil>)
GOROOT=/goroot (was <nil>)
GOTOOLCHAIN=local (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
wantArgv: []string{
"gocross", "build",
"-trimpath",
"-tags=tailscale_go,omitidna,omitpemdecrypt",
"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg",
"./cmd/tailcontrol",
},
},
{
name: "darwin_arm64_to_darwin_arm64_empty_goos",
argv: []string{"gocross", "build", "./cmd/tailcontrol"},
env: map[string]string{
"GOOS": "",
},
goroot: "/goroot",
nativeGOOS: "darwin",
nativeGOARCH: "arm64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=arm64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=darwin (was )
GOROOT=/goroot (was <nil>)
GOTOOLCHAIN=local (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
wantArgv: []string{
"gocross", "build",
"-trimpath",
"-tags=tailscale_go,omitidna,omitpemdecrypt",
"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg",
"./cmd/tailcontrol",
},
},
{
name: "darwin_arm64_to_darwin_arm64_empty_goarch",
argv: []string{"gocross", "build", "./cmd/tailcontrol"},
env: map[string]string{
"GOARCH": "",
},
goroot: "/goroot",
nativeGOOS: "darwin",
nativeGOARCH: "arm64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=arm64 (was )
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=darwin (was <nil>)
GOROOT=/goroot (was <nil>)
GOTOOLCHAIN=local (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
wantArgv: []string{
"gocross", "build",
@@ -295,7 +353,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "arm64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was amd64)
@@ -324,7 +382,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "arm64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=arm64 (was <nil>)
@@ -357,7 +415,7 @@ TS_LINK_FAIL_REFLECT=1 (was <nil>)`,
nativeGOARCH: "arm64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g -mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS=-mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was <nil>)
GOARCH=amd64 (was amd64)
@@ -390,7 +448,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -miphoneos-version-min=15.0 -isysroot /my/sdk/root -arch arm64 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g -miphoneos-version-min=15.0 -isysroot /my/sdk/root -arch arm64 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS=-miphoneos-version-min=15.0 -isysroot /my/sdk/root -arch arm64 (was <nil>)
GOARCH=arm64 (was arm64)
@@ -416,7 +474,7 @@ TS_LINK_FAIL_REFLECT=1 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -442,7 +500,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -471,7 +529,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -498,7 +556,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
@@ -528,7 +586,7 @@ TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
nativeGOARCH: "amd64",
envDiff: `CC=cc (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -g (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)

View File

@@ -106,7 +106,7 @@ type Server struct {
// AuthKey, if non-empty, is the auth key to create the node
// and will be preferred over the TS_AUTHKEY environment
// variable. If the node is already created (from state
// previously stored in in Store), then this field is not
// previously stored in Store), then this field is not
// used.
AuthKey string
@@ -562,14 +562,25 @@ func (s *Server) start() (reterr error) {
return ok
}
s.dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
// Note: don't just return ns.DialContextTCP or we'll
// return an interface containing a nil pointer.
// Note: don't just return ns.DialContextTCP or we'll return
// *gonet.TCPConn(nil) instead of a nil interface which trips up
// callers.
tcpConn, err := ns.DialContextTCP(ctx, dst)
if err != nil {
return nil, err
}
return tcpConn, nil
}
s.dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
// Note: don't just return ns.DialContextUDP or we'll return
// *gonet.UDPConn(nil) instead of a nil interface which trips up
// callers.
udpConn, err := ns.DialContextUDP(ctx, dst)
if err != nil {
return nil, err
}
return udpConn, nil
}
if s.Store == nil {
stateFile := filepath.Join(s.rootPath, "tailscaled.state")
@@ -908,6 +919,34 @@ func (s *Server) Listen(network, addr string) (net.Listener, error) {
return s.listen(network, addr, listenOnTailnet)
}
// ListenPacket announces on the Tailscale network.
//
// The network must be "udp", "udp4" or "udp6". The addr must be of the form
// "ip:port" (or "[ip]:port") where ip is a valid IPv4 or IPv6 address
// corresponding to "udp4" or "udp6" respectively. IP must be specified.
//
// If s has not been started yet, it will be started.
func (s *Server) ListenPacket(network, addr string) (net.PacketConn, error) {
ap, err := resolveListenAddr(network, addr)
if err != nil {
return nil, err
}
if !ap.Addr().IsValid() {
return nil, fmt.Errorf("tsnet.ListenPacket(%q, %q): address must be a valid IP", network, addr)
}
if network == "udp" {
if ap.Addr().Is4() {
network = "udp4"
} else {
network = "udp6"
}
}
if err := s.Start(); err != nil {
return nil, err
}
return s.netstack.ListenPacket(network, ap.String())
}
// ListenTLS announces only on the Tailscale network.
// It returns a TLS listener wrapping the tsnet listener.
// It will start the server if it has not been started yet.
@@ -1070,50 +1109,65 @@ const (
listenOnBoth = listenOn("listen-on-both")
)
func (s *Server) listen(network, addr string, lnOn listenOn) (net.Listener, error) {
switch network {
case "", "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6":
default:
return nil, errors.New("unsupported network type")
}
// resolveListenAddr resolves a network and address into a netip.AddrPort. The
// returned netip.AddrPort.Addr will be the zero value if the address is empty.
// The port must be a valid port number. The caller is responsible for checking
// the network and address are valid.
//
// It resolves well-known port names and validates the address is a valid IP
// literal for the network.
func resolveListenAddr(network, addr string) (netip.AddrPort, error) {
var zero netip.AddrPort
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("tsnet: %w", err)
return zero, fmt.Errorf("tsnet: %w", err)
}
port, err := net.LookupPort(network, portStr)
if err != nil || port < 0 || port > math.MaxUint16 {
// LookupPort returns an error on out of range values so the bounds
// checks on port should be unnecessary, but harmless. If they do
// match, worst case this error message says "invalid port: <nil>".
return nil, fmt.Errorf("invalid port: %w", err)
return zero, fmt.Errorf("invalid port: %w", err)
}
var bindHostOrZero netip.Addr
if host != "" {
bindHostOrZero, err = netip.ParseAddr(host)
if err != nil {
return nil, fmt.Errorf("invalid Listen addr %q; host part must be empty or IP literal", host)
}
if strings.HasSuffix(network, "4") && !bindHostOrZero.Is4() {
return nil, fmt.Errorf("invalid non-IPv4 addr %v for network %q", host, network)
}
if strings.HasSuffix(network, "6") && !bindHostOrZero.Is6() {
return nil, fmt.Errorf("invalid non-IPv6 addr %v for network %q", host, network)
}
if host == "" {
return netip.AddrPortFrom(netip.Addr{}, uint16(port)), nil
}
bindHostOrZero, err := netip.ParseAddr(host)
if err != nil {
return zero, fmt.Errorf("invalid Listen addr %q; host part must be empty or IP literal", host)
}
if strings.HasSuffix(network, "4") && !bindHostOrZero.Is4() {
return zero, fmt.Errorf("invalid non-IPv4 addr %v for network %q", host, network)
}
if strings.HasSuffix(network, "6") && !bindHostOrZero.Is6() {
return zero, fmt.Errorf("invalid non-IPv6 addr %v for network %q", host, network)
}
return netip.AddrPortFrom(bindHostOrZero, uint16(port)), nil
}
func (s *Server) listen(network, addr string, lnOn listenOn) (*listener, error) {
switch network {
case "", "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6":
default:
return nil, errors.New("unsupported network type")
}
host, err := resolveListenAddr(network, addr)
if err != nil {
return nil, err
}
if err := s.Start(); err != nil {
return nil, err
}
var keys []listenKey
switch lnOn {
case listenOnTailnet:
keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), false})
keys = append(keys, listenKey{network, host.Addr(), host.Port(), false})
case listenOnFunnel:
keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), true})
keys = append(keys, listenKey{network, host.Addr(), host.Port(), true})
case listenOnBoth:
keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), false})
keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), true})
keys = append(keys, listenKey{network, host.Addr(), host.Port(), false})
keys = append(keys, listenKey{network, host.Addr(), host.Port(), true})
}
ln := &listener{

View File

@@ -745,3 +745,73 @@ func TestCapturePcap(t *testing.T) {
t.Errorf("s2 pcap file size = %d, want > pcapHeaderSize(%d)", got, pcapHeaderSize)
}
}
func TestUDPConn(t *testing.T) {
tstest.ResourceCheck(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
controlURL, _ := startControl(t)
s1, s1ip, _ := startServer(t, ctx, controlURL, "s1")
s2, s2ip, _ := startServer(t, ctx, controlURL, "s2")
lc2, err := s2.LocalClient()
if err != nil {
t.Fatal(err)
}
// ping to make sure the connection is up.
res, err := lc2.Ping(ctx, s1ip, tailcfg.PingICMP)
if err != nil {
t.Fatal(err)
}
t.Logf("ping success: %#+v", res)
pc := must.Get(s1.ListenPacket("udp", fmt.Sprintf("%s:8081", s1ip)))
defer pc.Close()
// Dial to s1 from s2
w, err := s2.Dial(ctx, "udp", fmt.Sprintf("%s:8081", s1ip))
if err != nil {
t.Fatal(err)
}
defer w.Close()
// Send a packet from s2 to s1
want := "hello"
if _, err := io.WriteString(w, want); err != nil {
t.Fatal(err)
}
// Receive the packet on s1
got := make([]byte, 1024)
n, from, err := pc.ReadFrom(got)
if err != nil {
t.Fatal(err)
}
got = got[:n]
t.Logf("got: %q", got)
if string(got) != want {
t.Errorf("got %q, want %q", got, want)
}
if from.(*net.UDPAddr).AddrPort().Addr() != s2ip {
t.Errorf("got from %v, want %v", from, s2ip)
}
// Write a response back to s2
if _, err := pc.WriteTo([]byte("world"), from); err != nil {
t.Fatal(err)
}
// Receive the response on s2
got = make([]byte, 1024)
n, err = w.Read(got)
if err != nil {
t.Fatal(err)
}
got = got[:n]
t.Logf("got: %q", got)
if string(got) != "world" {
t.Errorf("got %q, want world", got)
}
}

View File

@@ -14,6 +14,7 @@ func TestDeps(t *testing.T) {
GOOS: "ios",
GOARCH: "arm64",
BadDeps: map[string]string{
"testing": "do not use testing package in production code",
"text/template": "linker bloat (MethodByName)",
"html/template": "linker bloat (MethodByName)",
},

View File

@@ -14,6 +14,7 @@ func TestDeps(t *testing.T) {
GOOS: "js",
GOARCH: "wasm",
BadDeps: map[string]string{
"testing": "do not use testing package in production code",
"runtime/pprof": "bloat",
"golang.org/x/net/http2/h2c": "bloat",
"net/http/pprof": "bloat",

View File

@@ -12,7 +12,7 @@ import (
// AccessLogRecord is a record of one HTTP request served.
type AccessLogRecord struct {
// Timestamp at which request processing started.
When time.Time `json:"when"`
Time time.Time `json:"time"`
// Time it took to finish processing the request. It does not
// include the entire lifetime of the underlying connection in
// cases like connection hijacking, only the lifetime of the HTTP
@@ -55,8 +55,8 @@ type AccessLogRecord struct {
// String returns m as a JSON string.
func (m AccessLogRecord) String() string {
if m.When.IsZero() {
m.When = time.Now()
if m.Time.IsZero() {
m.Time = time.Now()
}
var buf strings.Builder
json.NewEncoder(&buf).Encode(m)

View File

@@ -299,7 +299,7 @@ type retHandler struct {
// ServeHTTP implements the http.Handler interface.
func (h retHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
msg := AccessLogRecord{
When: h.opts.Now(),
Time: h.opts.Now(),
RemoteAddr: r.RemoteAddr,
Proto: r.Proto,
TLS: r.TLS != nil,
@@ -371,7 +371,7 @@ func (h retHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
lw.code = 200
}
msg.Seconds = h.opts.Now().Sub(msg.When).Seconds()
msg.Seconds = h.opts.Now().Sub(msg.Time).Seconds()
msg.Code = lw.code
msg.Bytes = lw.bytes

View File

@@ -87,7 +87,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/"),
wantCode: 200,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
TLS: false,
@@ -104,7 +104,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/"),
wantCode: 200,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
TLS: false,
@@ -121,7 +121,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 404,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -137,7 +137,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 404,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -153,7 +153,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 404,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -171,7 +171,7 @@ func TestStdHandler(t *testing.T) {
r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"),
wantCode: 404,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -190,7 +190,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 404,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -208,7 +208,7 @@ func TestStdHandler(t *testing.T) {
r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"),
wantCode: 404,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -227,7 +227,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 500,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -245,7 +245,7 @@ func TestStdHandler(t *testing.T) {
r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"),
wantCode: 500,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -264,7 +264,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 500,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -282,7 +282,7 @@ func TestStdHandler(t *testing.T) {
r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"),
wantCode: 500,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -301,7 +301,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 500,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -319,7 +319,7 @@ func TestStdHandler(t *testing.T) {
r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"),
wantCode: 500,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -338,7 +338,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 200,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -355,7 +355,7 @@ func TestStdHandler(t *testing.T) {
r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"),
wantCode: 200,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -373,7 +373,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 200,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -390,7 +390,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 200,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
Host: "example.com",
@@ -412,7 +412,7 @@ func TestStdHandler(t *testing.T) {
r: req(bgCtx, "http://example.com/foo"),
wantCode: 200,
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
@@ -432,7 +432,7 @@ func TestStdHandler(t *testing.T) {
http.Error(w, e.Msg, 200)
},
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
TLS: false,
@@ -455,7 +455,7 @@ func TestStdHandler(t *testing.T) {
http.Error(w, fmt.Sprintf("%s with request ID %s", e.Msg, requestID), 200)
},
wantLog: AccessLogRecord{
When: startTime,
Time: startTime,
Seconds: 1.0,
Proto: "HTTP/1.1",
TLS: false,

Some files were not shown because too many files have changed in this diff Show More