Compare commits

..

265 Commits

Author SHA1 Message Date
Xe
2716667c9d cmd/gitops-pusher: things i forgot to push last PR
Signed-off-by: Xe <xe@tailscale.com>
2022-07-22 15:14:39 -04:00
Xe
affa25097c cmd/gitops-pusher: add etag cache file for the three version problem
This allows gitops-pusher to detect external ACL changes. I'm not
sure what to call this problem, so I've been calling it the "three
version problem" in my notes. The basic problem is that at any given
time we only have two versions of the ACL file at any given point:
the version in CONTROL and the one in the git repo. In order to
check if there has been tampering of the ACL files in the admin
panel, we need to have a _third_ version to compare against.

In this case I am not storing the old ACL entirely (though that could
be a reasonable thing to add in the future), but only its sha256sum.
This allows us to detect if the shasum in control matches the shasum
we expect, and if that expectation fails, then we can react
accordingly.

This will require additional configuration in CI, but I'm sure that
can be done.

Signed-off-by: Xe <xe@tailscale.com>
2022-07-22 13:53:20 -04:00
Brad Fitzpatrick
227c6b2a53 ipn/ipnlocal: flesh out error on ssh host key parse error
Change-Id: Iedd2d3898befa536181036b9e9dea59bc777a440
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-22 08:09:59 -07:00
Xe Iaso
90ccba6730 cmd/gitops-pusher: port to use ffcli (#5113)
Signed-off-by: Xe <xe@tailscale.com>
2022-07-22 09:39:24 -04:00
Logan Saso
f7a36dfeb1 api.md: added missing quote to POST DNS Searchpaths request body example
Signed-off-by: Logan Saso <logansaso@gmail.com>
2022-07-21 17:44:51 -07:00
Maisem Ali
9514ed33d2 go.mod: bump gvisor.dev/gvisor
Pick up https://github.com/google/gvisor/pull/7787

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-07-21 16:41:18 -07:00
Maisem Ali
1d33157ab9 docs/k8s: use job control in run.sh
This has the benefit of propagating SIGINT to tailscaled, which in turn
can react to the event and logout in case of an ephemeral node.

Also fix missing run.sh in Dockerfile.

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-07-21 15:43:40 -07:00
Maisem Ali
3e06b9ea7a ssh/tailssh: add "ssh" to conn logs
Fixes #5089

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-07-21 12:42:31 -07:00
Maisem Ali
480fd6c797 ssh/tailssh: handle not-authenticated-yet connections in matchRule
Also make more fields in conn.info thread safe, there was previously a
data race here.

Fixes #5110

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-07-21 12:42:31 -07:00
Xe Iaso
41e60dae80 cmd/gitops-pusher: use fmt.Println for errors (#5112)
Signed-off-by: Xe <xe@tailscale.com>
2022-07-21 13:02:14 -04:00
Brad Fitzpatrick
43f3a969ca types/views: add SliceContains, View.ContainsFunc, View.IndexFunc
We were starting to write these elsewhere as little unexported copies
in misc places.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-21 08:28:43 -07:00
Brad Fitzpatrick
d8cb5aae17 tailcfg, control/controlclient: add tailcfg.PeersChangedPatch [capver 33]
This adds a lighter mechanism for endpoint updates from control.

Change-Id: If169c26becb76d683e9877dc48cfb35f90cc5f24
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-20 15:05:56 -07:00
Mihai Parparita
b763a12331 cmd/tsconnect: allow building static resources in a different directory
When using tsconnect as a module in another repo, we cannot write to
the ./dist directory (modules directories are read-only by default -
there is a -modcacherw flag for `go get` but we can't count on it).

We add a -distdir flag that is honored by both the build and serve
commands for where to place output in.

Somewhat tedious because esbuild outputs paths relative to the working
directory, so we need to do some extra munging to make them relative
to the output directory.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-07-20 10:14:43 -07:00
Mihai Parparita
de2dcda2e0 .github/workflows: update module that we try to build in cross-wasm
We now have the actual module that we need to build, so switch to
building it directly instead of its (expected) dependencies.

Also fix a copy/paste error in a jsdeps comment.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-07-20 10:13:30 -07:00
Brad Fitzpatrick
b7f1fe7b0d tailcfg: remove old DNS fields
The control plane server doesn't send these to modern clients so we
don't need them in the tree. The server has its own serialization code
to generate legacy MapResponses when needed.

Change-Id: Idd1e5d96ddf9d4306f2da550d20b77f0c252817a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-19 21:23:12 -07:00
Brad Fitzpatrick
9bd3b5b89c types/key: add ControlPrivate.Shared wrapper too
Follow-up to cfdb862673.

Change-Id: Iab610d761f1e6d88e8bcb584d9c02cafe48fc377
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-19 14:49:01 -07:00
Brad Fitzpatrick
cfdb862673 types/key: add naclbox shared key wrapper type + Seal method
So the control plane can stop doing precomputations on each naclbox
message.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-19 14:18:12 -07:00
Mihai Parparita
6f5096fa61 cmd/tsconnect: initial scaffolding for Tailscale Connect browser client
Runs a Tailscale client in the browser (via a WebAssembly build of the
wasm package) and allows SSH access to machines. The wasm package exports
a newIPN function, which returns a simple JS object with methods like
start(), login(), logout() and ssh(). The golang.org/x/crypto/ssh
package is used for the SSH client.

Terminal emulation and QR code renedring is done via NPM packages (xterm
and qrcode respectively), thus we also need a JS toolchain that can
install and bundle them. Yarn is used for installation, and esbuild
handles loading them and bundling for production serving.

Updates #3157

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-07-19 13:43:08 -07:00
Brad Fitzpatrick
2a22ea3e83 util/deephash: generate type-specific hasher funcs
name                old time/op    new time/op    delta
Hash-8                71.1µs ± 2%    71.5µs ± 1%     ~     (p=0.114 n=9+8)
HashPacketFilter-8    8.39µs ± 1%    4.83µs ± 2%  -42.38%  (p=0.000 n=8+9)
HashMapAcyclic-8      56.2µs ± 1%    56.9µs ± 2%   +1.17%  (p=0.035 n=10+9)
TailcfgNode-8         6.49µs ± 2%    3.54µs ± 1%  -45.37%  (p=0.000 n=9+9)
HashArray-8            729ns ± 2%     566ns ± 3%  -22.30%  (p=0.000 n=10+10)

name                old alloc/op   new alloc/op   delta
Hash-8                 24.0B ± 0%     24.0B ± 0%     ~     (all equal)
HashPacketFilter-8     24.0B ± 0%     24.0B ± 0%     ~     (all equal)
HashMapAcyclic-8       0.00B          0.00B          ~     (all equal)
TailcfgNode-8          0.00B          0.00B          ~     (all equal)
HashArray-8            0.00B          0.00B          ~     (all equal)

name                old allocs/op  new allocs/op  delta
Hash-8                  1.00 ± 0%      1.00 ± 0%     ~     (all equal)
HashPacketFilter-8      1.00 ± 0%      1.00 ± 0%     ~     (all equal)
HashMapAcyclic-8        0.00           0.00          ~     (all equal)
TailcfgNode-8           0.00           0.00          ~     (all equal)
HashArray-8             0.00           0.00          ~     (all equal)

Change-Id: I34c4e786e748fe60280646d40cc63a2adb2ea6fe
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-19 11:33:13 -07:00
Maisem Ali
4d0461f721 ipn/ipnlocal: logout ephemeral nodes on shutdown
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-07-19 11:29:31 -07:00
Tom DNetto
393a229de9 tka: implement synchronization mechanics
This PR implements the synchronization mechanics for TKA: generating a SyncOffer, processing a SyncOffer to find an intersection,
and computing the set of AUMs that should be transmitted to effect convergence.

This is the final PR implementing core mechanics for TKA.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-07-19 09:58:36 -07:00
Tom DNetto
165c8f898e tka: implement Authority API surface
After this, there should be one final PR to implement the Sync algorithm!

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-07-19 09:35:29 -07:00
Brad Fitzpatrick
2491fe1afe tailcfg: add missing omitempty annotation to PopBrowserURL
Change-Id: I8e752afd5bf009c17aae1b53650479b37c3232bd
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-19 09:25:06 -07:00
David Anderson
c1cb3efbba net/netcheck: test for OS IPv6 support as well as connectivity.
This lets us distinguish "no IPv6 because the device's ISP doesn't
offer IPv6" from "IPv6 is unavailable/disabled in the OS".

Signed-off-by: David Anderson <danderson@tailscale.com>
2022-07-18 18:02:12 -07:00
Charlotte Brandhorst-Satzkorn
4c0feba38e derp: plumb '/derp' request context through (#5083)
This change is required to implement tracing for derp.

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2022-07-18 15:43:03 -07:00
Denton Gentry
3c892d106c VERSION.txt: this is v1.29.0
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-07-18 10:23:52 -07:00
Maisem Ali
bd4b27753e docs/k8s: set statedir to /tmp when not specified
This makes `tailscale cert` and Taildrop work on k8s and in ephemeral
mode.

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-07-18 10:19:19 -07:00
Brad Fitzpatrick
469c30c33b ipn/localapi: define a cert dir for Synology DSM6
Fixes #4060

Change-Id: I5f145d4f56f6edb14825268e858d419c55918673
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-18 09:51:24 -07:00
Maisem Ali
c6648db333 cmd/tailscale/cli: make cert use localClient
This was seeminlgy missed in 87ba528ae0.

Fixes #5072

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-07-17 13:22:29 -07:00
Denton Gentry
9fcda1f0a0 cmd/tailscale/cli/web: add QNAP NAS_SID authentication
QTS 5.0 doesn't always pass a qtoken, in some circumstances
it sends a NAS_SID cookie for us to verify instead.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-07-17 12:21:03 -07:00
Brad Fitzpatrick
0d52674a84 net/tstun: diagnose /dev/net/tun fd leak, give better failure message
Updates #5029

Change-Id: Ibee5e0c9076fe764eb5d856d5ef8b09f4d0e2921
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-16 14:21:56 -07:00
Brad Fitzpatrick
931f18b575 derp: add missing docs on clientInfo.Version
It's not the Tailscale version.

Change-Id: Icfbd5ff36300b2125b19cd2fa6caa22876965317
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-15 12:00:19 -07:00
Tom DNetto
4f1374ec9e tka: implement consensus & state computation internals
Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-07-15 10:44:43 -07:00
Maisem Ali
af412e8874 ssh/tailssh: better handling of signals and exits
We were not handling errors occurred while copying data between the subprocess and the connection.
This makes it so that we pass the appropriate signals when to the process and the connection.

This also fixes mosh.

Updates #4919

Co-authored-by: James Tucker <raggi@tailscale.com>
Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-07-15 09:06:01 -07:00
Xe Iaso
004f0ca3e0 cmd/gitops-pusher: format HuJSON, enabling exact ACL matches (#5061)
Signed-off-by: Xe <xe@tailscale.com>
2022-07-15 11:09:44 -04:00
Xe Iaso
16c85d0dc5 cmd/gitops-pusher: support GitHub Actions error syntax (#5060)
GitHub Actions lets you annotate lines in files as errors[1]. This syntax
will only fire on syntax errors. Future improvements can extend this to
properly annotate ACL tests too.

[1]: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message

Signed-off-by: Xe <xe@tailscale.com>
2022-07-15 10:20:54 -04:00
Brad Fitzpatrick
7fb6781bda go.toolchain.rev: bump for Go 1.18.4 + runtime timer spin
See https://github.com/tailscale/go/pull/32

Updates #4760
Updates #5030?
Updates #4891?

Change-Id: I066aafddc09fade30a5f3fdee23e6bd200eda9fa
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-13 20:56:59 -07:00
Tom DNetto
ec4f849079 tka: implement filesystem-based tailchonk implementation
FS implements Chonk, and given the expected load characteristics (frequent use
of AUM() + ChildAUMs(), and infrequent use of Heads() + CommitVerifiedAUMs()), the
implementation avoids scanning the filesystem to service AUM() and ChildAUMs().

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-07-13 10:07:17 -07:00
Brad Fitzpatrick
505ca2750d cmd/tailscaled: fix Windows "Allow local LAN access" regression
3f686688a6 regressed the Windows beFirewallKillswitch code,
preventing the /firewall subprocess from running.

Fixes tailscale/corp#6063

Change-Id: Ibd105759e5fecfeffc54f587f8ddcd0f1cbc4dca
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-13 08:01:32 -07:00
David Anderson
96afd1db46 jsondb: small package to load/save JSON DBs.
Signed-off-by: David Anderson <danderson@tailscale.com>
2022-07-12 11:56:38 -07:00
Denton Gentry
755396d6fe tsweb: add Float expvar support in varz
We make assertions about stringification of 0.5. IEEE floating point and
all reasonable proprietary floating point can exactly represent 0.5.
We don't make assertions about other floating point values, too brittle
in tests.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-07-11 21:16:46 -07:00
Tom DNetto
cca25f6107 tka: implement Chonk type & an in-memory implementation
Chonks are responsible for efficient storage of AUMs and other TKA state.

For testing/prototyping I've implemented an in-memory version, but once we
start to use this from tailscaled we'll need a file-based version.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-07-11 10:38:29 -07:00
Mihai Parparita
e37167b3ef ipn/localapi: add API for uploading client metrics
Clients may have platform-specific metrics they would like uploaded
(e.g. extracted from MetricKit on iOS). Add a new local API endpoint
that allows metrics to be updated by a simple name/value JSON-encoded
struct.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-07-11 09:51:09 -07:00
Xe Iaso
d6b8a18b09 cmd/proxy-to-grafana: use localClient.GetCertificate (#5024)
Closes #5023

Signed-off-by: Xe <xe@tailscale.com>
2022-07-08 14:33:14 -04:00
Xe Iaso
5bb44a4a5c cmd/gitops-pusher: correctly handle ACL tests failing (#5016)
Apparently the API for running ACL tests returns a 200 if the ACL tests
fail. This is weird, but we can handle it.

Signed-off-by: Xe <xe@tailscale.com>
2022-07-08 10:53:50 -04:00
Andrew Dunham
c7993d2b88 net/dns/resolver: add fuzz/unit test for #2533 (#5018)
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2022-07-08 09:22:50 -04:00
Tom DNetto
3709074e55 tka: implement State and applying AUMs
Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-07-07 11:25:26 -07:00
Tom DNetto
1cfd96cdc2 tka: implement AUM and Key types
This is the first in a series of PRs implementing the internals for the
Tailnet Key Authority. This PR implements the AUM and Key types, which
are used by pretty much everything else. Future PRs:

 - The State type & related machinery
 - The Tailchonk (storage) type & implementation
 - The Authority type and sync implementation

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-07-06 12:19:37 -07:00
Denton Gentry
e6572a0f08 install.sh: Add archarm and Raspbian Stretch
Fixes https://github.com/tailscale/tailscale/issues/4959
Fixes https://github.com/tailscale/tailscale/issues/4897

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-07-06 05:41:06 -07:00
Joe Tsai
fe3426b4c7 logpolicy: fix config initialization bug (#5010)
If ConfigFromFile cannot find the configuration file,
we must not initialize it with NewConfig.
Instead, we need it to fail validation so that it eventually writes
a newly constructed configuration file.
Otherwise, new tailscale instances will never be able store a persistent
log config and start with a new config file upon every bootup.

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2022-07-05 18:27:31 -07:00
Tom DNetto
d6817d0f22 net/dns/resolver: respond with SERVFAIL if all upstreams fail
Fixes #4722

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-07-05 10:22:52 -07:00
Brad Fitzpatrick
c93fd0d22b go.mod: bump wireguard-go to set CLOEXEC on tun/netlink fds
To get:
c31a7b1ab4

Diff:
95b48cdb39..c31a7b1ab4

Fixes #4992

Change-Id: I077586fefcde923a2721712dd54548f97d010f72
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-03 17:11:49 -07:00
Craig Rodrigues
9584d8aa7d docs/k8s: Add env vars for tailscaled args
- TS_SOCKS5_SERVER, argument passed to tailscaled --socks5-server
- TS_OUTBOUND_HTTP_PROXY_LISTEN, argument passed to tailscaled -outbound-http-proxy-listen
- TS_TAILSCALED_EXTRA_ARGS extra arguments passed to tailscaled

Fixes #4985

Signed-off-by: Craig Rodrigues <rodrigc@crodrigues.org>
2022-07-01 14:45:51 -07:00
Jake Edgington
ea6e9099b9 fix: typo rename, ROUTES -> TS_ROUTES
Signed-off-by: Jake Edgington <jake.edgington@gmail.com>
2022-06-30 20:23:37 -07:00
Jake Edgington
72b7edbba9 fix: typo rename, KUBE_SECRET -> TS_KUBE_SECRET
Signed-off-by: Jake Edgington <jake.edgington@gmail.com>
2022-06-30 20:23:37 -07:00
Brad Fitzpatrick
6b71568eb7 util/cloudenv: add Azure support & DNS IPs
And rewrite cloud detection to try to do only zero or one metadata
discovery request for all clouds, only doing a first (or second) as
confidence increases. Work remains for Windows, but a start.

And add Cloud to tailcfg.Hostinfo, which helped with testing using
"tailcfg debug hostinfo".

Updates #4983 (Linux only)
Updates #4984

Change-Id: Ib03337089122ce0cb38c34f724ba4b4812bc614e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-30 17:03:46 -07:00
Mihai Parparita
ec649e707f ipn/ipnlocal: prefer to use one CGNAT route on the Mac
Together with 06aa141632 this minimizes
the number of NEPacketTunnelNetworkSettings updates that we have to do,
and thus avoids Chrome interrupting outstanding requests due to
(perceived) network changes.

Updates #3102

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-30 13:18:52 -07:00
Aaron Bieber
3f4fd64311 cmd/tailscale: exit loop when timeout and pingArgs.num are met
Currently if you use '-c' and ping a host that times out, ping will
continue running indefinitely. This change exits the loop with "no
reply" when we time out, hit the value specified by '-c' and do not
have anyPong. If we have anyPong it returns nil.

Signed-off-by: Aaron Bieber <aaron@bolddaemon.com>
2022-06-30 11:52:56 -07:00
Brad Fitzpatrick
aa37aece9c ipn/ipnlocal, net/dns*, util/cloudenv: add AWS DNS support
And remove the GCP special-casing from ipn/ipnlocal; do it only in the
forwarder for *.internal.

Fixes #4980
Fixes #4981

Change-Id: I5c481e96d91f3d51d274a80fbd37c38f16dfa5cb
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-29 20:37:44 -07:00
Brad Fitzpatrick
88c2afd1e3 ipn/ipnlocal, net/dns*, util/cloudenv: specialize DNS config on Google Cloud
This does three things:

* If you're on GCP, it adds a *.internal DNS split route to the
  metadata server, so we never break GCP DNS names. This lets people
  have some Tailscale nodes on GCP and some not (e.g. laptops at home)
  without having to add a Tailnet-wide *.internal DNS route.
  If you already have such a route, though, it won't overwrite it.

* If the 100.100.100.100 DNS forwarder has nowhere to forward to,
  it forwards it to the GCP metadata IP, which forwards to 8.8.8.8.
  This means there are never errNoUpstreams ("upstream nameservers not set")
  errors on GCP due to e.g. mangled /etc/resolv.conf (GCP default VMs
  don't have systemd-resolved, so it's likely a DNS supremacy fight)

* makes the DNS fallback mechanism use the GCP metadata IP as a
  fallback before our hosted HTTP-based fallbacks

I created a default GCP VM from their web wizard. It has no
systemd-resolved.

I then made its /etc/resolv.conf be empty and deleted its GCP
hostnames in /etc/hosts.

I then logged in to a tailnet with no global DNS settings.

With this, tailscaled writes /etc/resolv.conf (direct mode, as no
systemd-resolved) and sets it to 100.100.100.100, which then has
regular DNS via the metadata IP and *.internal DNS via the metadata IP
as well. If the tailnet configures explicit DNS servers, those are used
instead, except for *.internal.

This also adds a new util/cloudenv package based on version/distro
where the cloud type is only detected once. We'll likely expand it in
the future for other clouds, doing variants of this change for other
popular cloud environments.

Fixes #4911

RELNOTES=Google Cloud DNS improvements

Change-Id: I19f3c2075983669b2b2c0f29a548da8de373c7cf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-29 17:39:13 -07:00
Mihai Parparita
6f58497647 .github/workflows: downgrade MinGW to work around windows-race failures
Updates #4926

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-29 14:27:57 -07:00
Maisem Ali
88133c361e Docker: add ALPINE.txt to manage alpine versions
The goal here is to

1. make it so that the number doesn't diverge between the various places
   we had it defined
2. not define the number in corp, only in oss

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-29 11:47:09 -07:00
Maisem Ali
cfa484e1a2 Dockerfile: bump alpine to 3.16
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-29 10:29:37 -07:00
Brad Fitzpatrick
ef351ac0dd Dockerfile: fix build
The Dockerfile directions said:

But that failed with:

Step 14/15 : FROM ghcr.io/tailscale/alpine-base:3.14
Head "https://ghcr.io/v2/tailscale/alpine-base/manifests/3.14": denied: denied

So I guess the Dockerfile.base part was undocumented. But it only had
one line anyway, so move it here to avoid the intermediate layer's
published permissions problem entirely.

Also optimize the cachability a bit while here.

Change-Id: I846ad59fe7e88e6126925689fae78bfb80c279f0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-29 08:18:01 -07:00
Mihai Parparita
06aa141632 wgengine/router: avoid unncessary routing configuration changes
The iOS and macOS networking extension API only exposes a single setter
for the entire routing and DNS configuration, and does not appear to
do any kind of diffing or deltas when applying changes. This results
in spurious "network changed" errors in Chrome, even when the
`OneCGNATRoute` flag from df9ce972c7 is
used (because we're setting the same configuration repeatedly).

Since we already keep track of the current routing and DNS configuration
in CallbackRouter, use that to detect if they're actually changing, and
only invoke the platform setter if it's actually necessary.

Updates #3102

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-28 16:59:37 -07:00
Brad Fitzpatrick
3b1f99ded1 ssh/tailssh: fix Tailscale SSH to Linux Arch machines
See https://github.com/tailscale/tailscale/issues/4924#issuecomment-1168201823

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.

Thanks to @eddiezane for debugging!

Updates #4924

Change-Id: I8d33e0afb2dfb99517bcea2f9d5d0c6247519b3c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-28 15:35:51 -07:00
kylecarbs
9280d39678 wgengine/netstack: close ipstack when netstack.Impl is closed
Fixes netstack.Impl leaking goroutines after shutdown.

Signed-off-by: kylecarbs <kyle@carberry.com>
2022-06-28 14:59:29 -07:00
Denton Gentry
d7f452c0a1 net/portmapper: send discovery packet for IGD specifically.
There appear to be devices out there which send only their
first descriptor in response to a discovery packet for
`ssdp:all`, for example the Sagemcom FAST3890V3 only sends
urn:schemas-wifialliance-org:device:WFADevice:1

Send both ssdp:all and a discovery frame for
InternetGatewayDevice specifically.

Updates https://github.com/tailscale/tailscale/issues/3557

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-06-28 03:55:17 -07:00
Denton Gentry
09eaba91ad net/portmap: add a test for Sagemcom FAST3890V3.
Updates https://github.com/tailscale/tailscale/issues/3557

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-06-28 03:55:17 -07:00
Denton Gentry
b5553c6ad2 net/portmap: run go fmt
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-06-28 03:55:17 -07:00
Denton Gentry
de4c635e54 net/portmapper: make pcpCodeNotAuthorized log more descriptive
If PCP is present but disabled, turning it on might help
get direct connections.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-06-28 03:55:17 -07:00
Brad Fitzpatrick
3ac8ab1791 tsnet: add Server.AuthKey field
... so callers can provide the AuthKey via mechanisms other than
environment variables which means multiple Servers can't be started
concurrently in the same process without coordination.

Change-Id: I7736ef4f59b7cc29637939e140e990613ce58e0d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-27 21:57:57 -07:00
Jordan Whited
bef6e2831a cmd/tailscale: move call to cli.CleanUpArgs() from main() into cli.Run() (#4954)
Not all distributions build from package main.

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2022-06-27 14:56:25 -07:00
Maisem Ali
40503ef07a ssh/tailssh: fix logging typo
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-27 13:23:09 -07:00
soypete
412c4c55e2 cmd/tailscale: make up respect explicitly empty --operator= value
Fixes #3808

Signed-off-by: soypete <miriah@tailscale.com>
2022-06-27 13:59:45 -06:00
Maisem Ali
c434e47f2d ssh/tailssh: always use current time for policy evaluation
Whenever the SSH policy changes we revaluate all open connections to
make sure they still have access. This check was using the wrong
timestamp and would match against expired policies, however this really
isn't a problem today as we don't have policy that would be impacted by
this check. Fixing it for future use.

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-27 12:25:04 -07:00
Maisem Ali
a7d2024e35 ssh/tailssh: allow multiple sessions on the same conn
Fixes #4920
Fixes tailscale/corp#5633
Updates #4479

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-27 11:54:54 -07:00
mattn
1d04e01d1e use C:\Windows\System32\OpenSSH\ssh.exe (#4933)
cmd/tailscale: make ssh command prefer Windows ssh.exe over PATH

Signed-off-by: Yasuhiro Matsumoto <mattn.jp@gmail.com>
2022-06-25 22:26:21 -07:00
Adam Eijdenberg
9294a14a37 ssh/tailssh: limit setgroups to 16 on macOS
Fixes #4938

Signed-off-by: Adam Eijdenberg <adam@continusec.com>
2022-06-25 22:17:53 -07:00
Adam Eijdenberg
7f807fef6c ssh/tailssh: fix /usr/bin/login args on macOS
Fixes #4931

Signed-off-by: Adam Eijdenberg <adam@continusec.com>
2022-06-25 22:17:53 -07:00
Brad Fitzpatrick
35782f891d util/deephash: add canMemHash func + typeInfo property
Currently unused. (breaking up a bigger change)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-25 13:09:30 -07:00
Brad Fitzpatrick
7b9a901489 util/deephash: add packet filter benchmark
(breaking up parts of another change)

This adds a PacketFilter hashing benchmark with an input that both
contains every possible field, but also is somewhat representative in
the shape of what real packet filters contain.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-25 12:55:15 -07:00
James Tucker
c88bd53b1b .github/workflows: windows-race: print gcc version
Signed-off-by: James Tucker <james@tailscale.com>
2022-06-24 15:30:36 -07:00
Maisem Ali
2d65c1a950 client/tailscale: update ACLRow and ACLTest with new ACL fields
Signed-off-by: Maisem Ali <maisem@tailscale.com>
Co-authored-by: Will Norris <will@tailscale.com>
2022-06-24 12:20:08 -07:00
Aaron Klotz
4baf34cf25 net/dns: set appropriate Windows registry values to prevent it from sending DNS changes concerning our interface to AD domain controllers.
We do this unconditionally inside SetDNS such that the values are always set
before we make any other changes to DNS configurations.

It should not be harmful for the settings to remain even when other DNS
settings are cleared out (since they only affect our network interface).

See https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/configure-dns-dynamic-updates-windows-server-2003 for details about the registry value.

Fixes https://github.com/tailscale/tailscale/issues/4829

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2022-06-24 11:41:33 -06:00
Aaron Klotz
8cdfd12977 net/dns: update Windows split DNS settings to work alongside other NRPT entries set by group policy.
When there are group policy entries for the NRPT that do not belong to Tailscale,
we recognize that we need to add ourselves to group policy and use that registry
key instead of the local one. We also refresh the group policy settings as
necessary to ensure that our changes take effect immediately.

Fixes https://github.com/tailscale/tailscale/issues/4607

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2022-06-23 15:42:22 -06:00
James Tucker
76256d22d8 wgengine/router: windows: set SkipAsSource on IPv6 LL addresses
Link-local addresses on the Tailscale interface are not routable.
Ideally they would be removed, however, a concern exists that the
operating system will attempt to re-add them which would lead to
thrashing.

Setting SkipAsSource attempts to avoid production of packets using the
address as a source in any default behaviors.

Before, in powershell: `ping (hostname)` would ping the link-local
address of the Tailscale interface, and fail.
After: `ping (hostname)` now pings the link-local address on the next
highest priority metric local interface.

Fixes #4647
Signed-off-by: James Tucker <james@tailscale.com>
2022-06-22 15:26:40 -07:00
Brad Fitzpatrick
8c5c87be26 util/deephash: fix collisions between different types
Updates #4883

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-21 22:29:04 -07:00
Tom DNetto
4f6fa3d63a shell.nix: use 1.18 from system nixpkgs
(Now that 22.05 / 22.11 are released and presumably everyone has updated).

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-06-21 15:36:39 -07:00
Xe Iaso
dee95d0894 cmd/gitops-pusher: add new GitOps assistant (#4893)
This is for an upcoming blogpost on how to manage Tailscale ACLs using a
GitOps flow. This tool is intended to be used in CI and will allow users
to have a git repository be the ultimate source of truth for their ACL
file. This enables ACL changes to be proposed, approved and discussed
before they are applied.

Signed-off-by: Xe <xe@tailscale.com>
2022-06-21 16:04:48 -04:00
Sofiane Hamlaoui
1007983159 Adding pkg for Freebsd installation
Signed-off-by: Sofiane Hamlaoui <s0fianehaml40u1@gmail.com>
2022-06-20 11:31:10 -07:00
Sofiane Hamlaoui
51cc0e503b adding --noconfirm to pacman installation
Signed-off-by: Sofiane Hamlaoui <s0fianehaml40u1@gmail.com>
2022-06-20 11:31:10 -07:00
Brad Fitzpatrick
87a4c75fd4 control/controlclient, ipn/ipnlocal: remove Client.SetExpirySooner, fix race
Client.SetExpirySooner isn't part of the state machine. Remove it from
the Client interface.

And fix a use of LocalBackend.cc without acquiring the lock that
guards that field.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-19 22:08:29 -07:00
Brad Fitzpatrick
ef0d740270 control/controlclient: remove Client.SetStatusFunc
It can't change at runtime. Make it an option.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-19 21:54:39 -07:00
Brad Fitzpatrick
70a2797064 control/controlclient, ipn/ipnlocal: remove some Client methods
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-19 18:11:59 -07:00
Brad Fitzpatrick
a1e429f7c3 control/controlclient, types/netmap: remove unused LocalPort field
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-19 17:22:00 -07:00
Brad Fitzpatrick
526b0b6890 control/controlclient: start simplifying netmap fetch APIs
Step 1 of many, cleaning up the direct/auto client & restarting map
requests that leads to all the unnecessary map requests.

Updates tailscale/corp#5761

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-19 15:43:12 -07:00
Brad Fitzpatrick
467eb2eca0 cmd/tailscale/cli, ipn/ipnlocal: give SSH tips when off/unconfigured
Updates #3802

Change-Id: I6b9a3175f68a6daa670f912561f2c2ececc07770
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-17 19:37:59 -07:00
Brad Fitzpatrick
99ed54926b tailcfg: define some Node.Capabilities about SSH, its config
Updates #3802

Change-Id: Icb4ccbc6bd1c6304013bfc553d04007844a5c0bf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-17 12:14:50 -07:00
Brad Fitzpatrick
13d0b8e6a4 control/controlclient, net/dnscache: use typed singleflight fork
Change-Id: I12be4c5a91ae3a812fe88d9b2d15526fdbb5a921
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-17 10:20:16 -07:00
Brad Fitzpatrick
59ed846277 util/singleflight: add fork of singleflight with generics
Forked from golang.org/x/sync/singleflight at
the x/sync repo's commit 67f06af15bc961c363a7260195bcd53487529a21

Updates golang/go#53427

Change-Id: Iec2b47b7777940017bb9b3db9bd7d93ba4a2e394
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-17 10:20:16 -07:00
Brad Fitzpatrick
757ecf7e80 util/deephash: fix map hashing when key & element have the same type
Regression from 09afb8e35b, in which the
same reflect.Value scratch value was being used as the map iterator
copy destination.

Also: make nil and empty maps hash differently, add test.

Fixes #4871

Co-authored-by: Josh Bleecher Snyder <josharian@gmail.com>
Change-Id: I67f42524bc81f694c1b7259d6682200125ea4a66
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-16 22:29:47 -07:00
Brad Fitzpatrick
22c544bca7 go.mod: bump go4.org/unsafe/assume-no-moving-gc for Go 1.19beta1
Otherwise we crash at startup with Go 1.19beta1.

Updates #4872

Change-Id: I371df4146735f7e066efd2edd48c1a305906c13d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-16 20:40:22 -07:00
Brad Fitzpatrick
f31588786f util/deephash: don't track cycles on non-recursive types
name              old time/op    new time/op    delta
Hash-8              67.3µs ±20%    76.5µs ±16%     ~     (p=0.143 n=10+10)
HashMapAcyclic-8    63.0µs ± 2%    56.3µs ± 1%  -10.65%  (p=0.000 n=10+8)
TailcfgNode-8       9.18µs ± 2%    6.52µs ± 3%  -28.96%  (p=0.000 n=9+10)
HashArray-8          732ns ± 3%     709ns ± 1%   -3.21%  (p=0.000 n=10+10)

name              old alloc/op   new alloc/op   delta
Hash-8               24.0B ± 0%     24.0B ± 0%     ~     (all equal)
HashMapAcyclic-8     0.00B          0.00B          ~     (all equal)
TailcfgNode-8        0.00B          0.00B          ~     (all equal)
HashArray-8          0.00B          0.00B          ~     (all equal)

name              old allocs/op  new allocs/op  delta
Hash-8                1.00 ± 0%      1.00 ± 0%     ~     (all equal)
HashMapAcyclic-8      0.00           0.00          ~     (all equal)
TailcfgNode-8         0.00           0.00          ~     (all equal)
HashArray-8           0.00           0.00          ~     (all equal)

Change-Id: I28642050d837dff66b2db54b2b0e6d272a930be8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-16 18:39:10 -07:00
Brad Fitzpatrick
36ea837736 util/deephash: fix map hashing to actually hash elements
Fixes #4868

Change-Id: I574fd139cb7f7033dd93527344e6aa0e625477c7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-16 11:32:12 -07:00
Brad Fitzpatrick
4005134263 tailcfg: clarify some of the MapRequest variants
Change-Id: Ia09bd69856e372c3a6b64cda7ddb34029b32a11b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-14 13:08:55 -07:00
Joe Tsai
0f05b2c13f tsnet: use tailscaled as prefix for state file names (#4816)
Use the "tailscaled" prefix instead of "tsnet" for state file names:
1. It is consistent with the pre-existing {{Dir}}/tailscaled.state file.
2. It makes the file layout of `tsnet` and `tailscaled` identical,
   so that they are compatible with each other.

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2022-06-09 11:46:43 -07:00
Denton Gentry
1392a93445 socks5: add a simple test
Start up a backend service, put a SOCKS5 server in front
of it, and verify that we can get data from the backend via
SOCKS5.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-06-09 10:41:06 -07:00
Denton Gentry
7fffddce8e net/portmapper: enable for iOS
In the 1.27 unstable releases we set the min-version to iOS15,
which means we have 50 MBytes of RAM in the Network Extension.
https://tailscale.com/blog/go-linker/

Include the UPnP/NAT-PMP/PCP portmapper support now that there
is memory for it.

Fixes https://github.com/tailscale/tailscale/issues/2495
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-06-09 10:40:25 -07:00
Mihai Parparita
2990c2b1cf control/controlhttp: use secure WebSockets protocol by default
Forcing the insecure protocol (and perserving the port number) is only
desired for localhost testing, in prod we need to use wss:// to avoid
mixed-content errors.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-08 15:29:21 -07:00
Tom DNetto
32c6823cf5 tsweb: implement interceptor for error page presentation
Updates https://github.com/tailscale/corp/issues/5605

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-06-08 15:21:16 -07:00
Mihai Parparita
b788a5ba7e tailcfg: add HostinfoView version of TailscaleSSHEnabled
Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-08 12:08:14 -07:00
Praneet Loke
d8c05fc1b2 Update a Taildrop error message (#4818)
ipn/ipnlocal: update error message on Taildrop send while not connected

Signed-off-by: Praneet <1466314+praneetloke@users.noreply.github.com>
2022-06-08 09:11:50 -07:00
Brad Fitzpatrick
d3643fa151 cmd/tailscale: add 'debug ts2021' Noise connectivity subcommand
Updates #3488

Change-Id: I9272e68f66c4cf36fb98dd1248a74d3817447690
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-08 09:07:05 -07:00
Joe Tsai
96f73b3894 logtail: do not panic in PrivateID.PublicID (#4815)
It is not idiomatic for Go code to panic for situations that
can be normal. For example, if a server receives PrivateID
from a client, it is normal for the server to call
PrivateID.PublicID to validate that the PublicID matches.
However, doing so would panic prior to this change.

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2022-06-07 17:25:05 -07:00
Melanie Warrick
3a182d5dd6 ipn/ipnstate: add ExitNodeStatus to share the exit node if it is in use, the IP, ID and whether its online. (#4761)
-
Updates #4619

Signed-off-by: nyghtowl <warrick@tailscale.com>
2022-06-07 12:31:10 -07:00
Mihai Parparita
6246fa32f0 wasm: add test for disallowed JS/WASM dependencies
Ensures that binary size gains like the ones from #4802 and #4813
don't regress.

Updates #3517

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-07 12:19:11 -07:00
Mihai Parparita
c41837842b wasm: drop pprof dependency
We can use the browser tools to profile, pprof adds 200K to the binary size.

Updates #3157

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-07 12:16:16 -07:00
Ross Zurowski
bb67999808 tailcfg: add TailscaleSSHEnabled helper check (#4812)
This commit adds a helper to check if Tailscale SSH is enabled. We're
currently checking the SSH_HostKeys field in a few places, but later
plan to add an explicit bool. This helper makes the check and any future
changes easier.

Signed-off-by: Ross Zurowski <ross@rosszurowski.com>
2022-06-07 14:50:08 -04:00
Will Norris
09363064b5 tsnet: use proper log ID
refactor logpolicy config loading to make it easier to reuse from
outside the package.  Within tsnet, setup a basic logtail config.

Signed-off-by: Will Norris <will@tailscale.com>
2022-06-07 10:09:21 -07:00
Mihai Parparita
edc90ebc61 net/wsconn: remove homegrown wrapper for turning a websocket.Conn into a net.Conn
The one from the nhooyr/websocket package seems to work equally well.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-07 09:28:56 -07:00
Denton Gentry
cfb5bd0559 VERSION.txt: this is v1.27.0
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-06-06 16:11:18 -07:00
Brad Fitzpatrick
db83926121 go.toolchain.rev: bump Go to 1.18.3 (+ Tailscale patches)
See 04d67b90d8

Diff:

bb6009ec7c..04d67b90d8

Change-Id: Ic0abd3058f3696c3f8007e1004ab4bf377c5323c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-06 15:46:28 -07:00
Mihai Parparita
27a1ad6a70 wasm: exclude code that's not used on iOS for Wasm too
It has similar size constraints. Saves ~1.9MB from the Wasm build.

Updates #3157

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-06 13:52:52 -07:00
Brad Fitzpatrick
4007601f73 cmd/controlclient: wire up PingRequest peerapi pings too
Updates tailscale/corp#754

Change-Id: I61ac3fc44783b54bd02455bcb0baf19159b7a9d2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-06 13:41:34 -07:00
Maisem Ali
3b55bf9306 build_docker.sh: add run.sh as an entrypoint to the docker image
Fixes #4071

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-07 00:45:49 +05:00
Maisem Ali
bf2fa7b184 go.mod: pin github.com/tailscale/mkctr (try #2)
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-07 00:44:21 +05:00
Brad Fitzpatrick
0d972678e7 cmd/tailscale/cli: disable 'tailscale ssh' on sandboxed macOS
Updates #3802
Updates #4518
Fixes #4628

Change-Id: I194d2cc30fc8e38b66d4910787efbce14317b0ff
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-06 08:54:38 -07:00
Brad Fitzpatrick
0df3b76c25 tsweb: fix Port80Handler redirect to https with FQDN unset
Fixes the current http://pkgs.tailscale.com/ redirect to https:///
as that server doesn't configure the Port80Handler.FQDN field.

Change-Id: Iff56e6127a46c306ca97738d91b217bcab32a582
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-05 13:01:30 -07:00
Denton Gentry
c6ac82e3a6 hostinfo,distro: Identify Western Digital MyCloud devices.
root@WDMyCloud HD_a2 # ./tailscale debug hostinfo
{
  "IPNVersion": "1.25.0-dev20220605-t7fea52e02",
  "OS": "linux",
  "OSVersion": "5.22.113",
  "Desktop": false,
  "DeviceModel": "WD My Cloud Gen2: Marvell Armada 375",
  "Hostname": "WDMyCloud",
  "GoArch": "arm"
}

Updates https://github.com/tailscale/tailscale/issues/4622

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-06-05 08:22:42 -07:00
Denton Gentry
0687195bee logpolicy: put Synology logs buffer in /tmp
Ongoing log writing keeps the spinning disks from hibernating.
Fixes https://github.com/tailscale/tailscale/issues/3551

Tested on DSM6 and DSM7.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-06-04 22:06:14 -07:00
Brad Fitzpatrick
fbc079d82d ipn/ipnlocal: prevent attempting to run SSH on Synology for now
On DSM7 as a non-root user it'll run into problems.

And we haven't tested on DSM6, even though it might work, but I doubt
it.

Updates #3802
Updates tailscale/corp#5468

Change-Id: I75729042e4788f03f9eb82057482a44b319f04f3
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-03 13:50:33 -07:00
Brad Fitzpatrick
2bac8b6013 Revert "cmd/tailscale/cli: disallow --ssh on Synology"
This reverts commit 03e3e6abcd
in favor of #4785.

Change-Id: Ied65914106917c4cb8d15d6ad5e093a6299d1d48
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-03 13:49:42 -07:00
Maisem Ali
03e3e6abcd cmd/tailscale/cli: disallow --ssh on Synology
Updates tailscale/corp#5468
Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-04 01:39:33 +05:00
Brad Fitzpatrick
a9b4bf1535 ipn/ipnserver, cmd/tailscaled: fix peerapi on Windows
We weren't wiring up netstack.Impl to the LocalBackend in some cases
on Windows. This fixes Windows 7 when run as a service.

Updates #4750 (fixes after pull in to corp repo)

Change-Id: I9ce51b797710f2bedfa90545776b7628c7528e99
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-03 12:24:47 -07:00
Jordan Whited
43f9c25fd2 cmd/tailscale: surface authentication errors in status.Health (#4748)
Fixes #3713

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2022-06-03 10:52:07 -07:00
Charlotte Brandhorst-Satzkorn
c980bf01be words: The lists continue to drag-on. (#4780)
For Zaku and Pretzel.

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2022-06-03 10:55:17 -04:00
Mihai Parparita
a9f32656f5 control/controlhttp: allow client and server to communicate over WebSockets
We can't do Noise-over-HTTP in Wasm/JS (because we don't have bidirectional
communication), but we should be able to do it over WebSockets. Reuses
derp WebSocket support that allows us to turn a WebSocket connection
into a net.Conn.

Updates #3157

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-02 21:20:54 -07:00
Maisem Ali
80157f3f37 net/dns/resolver: add support for <IPv4>.via-<site-id>
Currently we only support "via-<site-id>.<IPv4>", however that does not
work with Google Chrome which parses `http://via-1.10.0.0.1` as a search
string and not as a URL. This commit introduces "<IPv4>.via-<site-id>"
(`http://10.0.0.1.via-1`) which is parsed correctly by Chrome.

Updates #3616

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-02 23:20:37 +05:00
Brad Fitzpatrick
69b535c01f wgengine/netstack: replace a 1500 with a const + doc
Per post-submit code review feedback of 1336fb740b from @maisem.

Change-Id: Ic5c16306cbdee1029518448642304981f77ea1fd
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-02 08:24:35 -07:00
Brad Fitzpatrick
e428bba7a3 ssh/tailssh: add metrics
Updates #3802

Change-Id: Ic9a4b8c51cff6dfe148a1c78bc0e5074195b7f80
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-02 08:18:53 -07:00
Maisem Ali
67325d334e cmd/tailscale/cli: add lose-ssh risk
This makes it so that the user is notified that the action
they are about to take may result in them getting disconnected from
the machine. It then waits for 5s for the user to maybe Ctrl+C out of
it.

It also introduces a `--accept-risk=lose-ssh` flag for automation, which
allows the caller to pre-acknowledge the risk.

The two actions that cause this are:
- updating `--ssh` from `true` to `false`
- running `tailscale down`

Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-02 13:14:43 +05:00
Brad Fitzpatrick
1336fb740b wgengine/netstack: make netstack MTU be 1280 also
Updates #3878

Change-Id: I1850085b32c8a40d85607b4ad433622c97d96a8d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-06-01 12:16:41 -07:00
Maisem Ali
81487169f0 build_docker.sh: pin github.com/tailscale/mkctr
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-06-01 19:55:06 +05:00
Tobias Klauser
3a926348a4 hostinfo: use ByteSliceToString from golang.org/x/sys/unix
Use unix.ByteSliceToString in osVersionFreebsd and osVersionLinux to
convert the Utsname.Release []byte field to string.

Signed-off-by: Tobias Klauser <tklauser@distanz.ch>
2022-06-01 07:43:44 -07:00
Tobias Klauser
2a61261a5a hostinfo: use Uname from golang.org/x/sys/unix in osVersionLinux
As already done in osVersionFreebsd. This will allow to use the Utsname
fields as []byte for easier conversion to string.

Signed-off-by: Tobias Klauser <tklauser@distanz.ch>
2022-06-01 07:43:44 -07:00
Maisem Ali
928530a112 ipn/ipnlocal: shutdown sshServer on tailscale down
Also lazify SSHServer initialization to allow restarting the server on a
subsequent `tailscale up`

Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-30 15:01:22 +05:00
Brad Fitzpatrick
4d85cf586b cmd/tailscale, ipn/ipnlocal: add "peerapi" ping type
For debugging when stuff like #4750 isn't working.

RELNOTE=tailscale ping -peerapi

Change-Id: I9c52c90fb046e3ab7d2b121387073319fbf27b99
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-28 13:47:12 -07:00
Maisem Ali
575aacb1e2 ssh/tailssh: terminate sessions on stdout copy failures
Currently, killing a SCP copy with a Ctrl+C leaves the session hanging
even though the stdout copy goroutine fails with an io.EOF. Taking a
step back, when we are unable to send any more data back to the client
we should just terminate the session as the client will stop getting any
response from the server anyways.

Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-28 21:30:54 +05:00
Maisem Ali
7cd8c3e839 ssh/tailssh: terminate sessions when tailscaled shutsdown
Ideally we would re-establish these sessions when tailscaled comes back
up, however we do not do that yet so this is better than leaking the
sessions.

Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-28 21:30:54 +05:00
Maisem Ali
760740905e ssh/tailssh: only use login with TTY sessions
Otherwise, the shell exits immediately causing applications like mosh
and VSCode to fail.

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-28 21:03:40 +05:00
Brad Fitzpatrick
02e580c1d2 logtail: use http.NewRequestWithContext
Saves some allocs. Not hot, but because we can now.

And a const instead of a var.

Change-Id: Ieb2b64534ed38051c36b2c0aa2e82739d9d0e015
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-27 16:33:43 -07:00
Tom
2903d42921 wgengine/router: delete hardcoded link-local address on Windows (#4740)
Fixes #4647

It seems that Windows creates a link-local address for the TUN driver, seemingly
based on the (fixed) adapter GUID. This results in a fixed MAC address, which
for some reason doesn't handle loopback correctly. Given the derived link-local
address is preferred for lookups (thanks LLMNR), traffic which addresses the
current node by hostname uses this broken address and never works.

To address this, we remove the broken link-local address from the wintun adapter.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-05-27 14:42:55 -07:00
Aaron Klotz
b005b79236 net/dns, paths, util/winutil: change net/dns/windowsManager NRPT management to support more than 50 domains.
AFAICT this isn't documented on MSDN, but based on the issue referenced below,
NRPT rules are not working when a rule specifies > 50 domains.

This patch modifies our NRPT rule generator to split the list of domains
into chunks as necessary, and write a separate rule for each chunk.

For compatibility reasons, we continue to use the hard-coded rule ID, but
as additional rules are required, we generate new GUIDs. Those GUIDs are
stored under the Tailscale registry path so that we know which rules are ours.

I made some changes to winutils to add additional helper functions in support
of both the code and its test: I added additional registry accessors, and also
moved some token accessors from paths to util/winutil.

Fixes https://github.com/tailscale/coral/issues/63

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2022-05-27 14:56:09 -06:00
Joel Cressy
c16271fb46 ipn/localapi: implement LoginInteractive via localapi
Updates: #4738

Signed-off-by: Joel Cressy <joel@jtcressy.net>
2022-05-27 06:34:27 -07:00
Denton Gentry
0f95eaa8bb scripts/installer: fix elementaryOS
c2b907c965 moved UBUNTU_VERSION out
of the ubuntu case and into linuxmint, but linuxmint wasn't the
only Ubuntu-based system which needed it. Restore UBUNTU_VERSION
handling in the ubuntu case.

Break elementaryOS out into its own handling so we can get the
version number handling correct for keyring support.
Tested on an elementaryOS 6.1 VM.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-05-27 06:21:15 -07:00
James Tucker
3f686688a6 cmd/tailscaled: fix state path for windows svc
Signed-off-by: James Tucker <james@tailscale.com>
2022-05-26 17:59:50 -07:00
Aaron Klotz
c163b2a3f1 util/winutil, util/winutil/vss: remove winrestore and vss as they are unnecessary.
I wrote this code way back at the beginning of my tenure at Tailscale when we
had concerns about needing to restore deleted machine keys from backups.

We never ended up using this functionality, and the code is now getting in the
way, so we might as well remove it.

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2022-05-26 12:13:36 -06:00
Tom
fc5839864b wgengine/netstack: handle multiple magicDNS queries per UDP socket (#4708)
Fixes: #4686

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-05-20 13:30:11 -07:00
Brad Fitzpatrick
c81be7b899 control/controlclient: avoid Noise protocol for js/wasm for now
Updates #3157

Change-Id: I04accc09783a68257d28cadde5818bf0724a8013
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-20 12:09:09 -07:00
Denton Gentry
36af49ae7f install.sh: add RHEL9.
Fixes https://github.com/tailscale/tailscale/issues/4718

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-05-20 09:49:39 -07:00
Mihai Parparita
bded712e58 .github/workflows: fix duplicated build target in Wasm action
Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-05-20 09:31:14 -07:00
Mihai Parparita
7cfc6130e5 .github/workflows: add cross-builder for Wasm
For now just checks that we can build cmd/tailscale/cli, will be
broadened once we can actually build more things.

Updates #3157

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-05-19 20:57:31 -07:00
Mihai Parparita
eda647cb47 cmd/tailscale/cli: fix ssh CLI command breaking the Wasm build
Adds a stub for syscall.Exec when GOOS=js. We also had a separate branch
for Windows, might as well use the same mechanism there too.

For #3157

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-05-19 20:49:34 -07:00
Brad Fitzpatrick
cc91a05686 ipn/ipnserver: fix build on js/wasm
Broken by 3dedcd1640 but we don't have CI coverage yet.

Updates #3157

Change-Id: Ie8e95ebd36264887fdeed16fc9f25a857d48124b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-19 15:01:27 -07:00
Mihai Parparita
3222bce02d logtail: add instance metadata to the entry logtail
Allows instances that are running with the same machine ID (due to
cloning) to be distinguished.

Also adds sequence numbers to detect duplicates.

For tailscale/corp#5244

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-05-18 13:57:14 -07:00
Tom
acfe5bd33b net/dns{., resolver}: time out DNS queries after 10 seconds (#4690)
Fixes https://github.com/tailscale/corp/issues/5198

The upstream forwarder will block indefinitely on `udpconn.ReadFrom` if no
reply is recieved, due to the lack of deadline on the connection object.

There still isn't a deadline on the connection object, but the automatic closing
of the context on deadline expiry will close the connection via `closeOnCtxDone`,
unblocking the read and resulting in a normal teardown.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-05-18 10:40:04 -07:00
Tom
ec4c49a338 types/key: make NodePublic implement Shardable (#4698)
Needed for an experiment in Control.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-05-17 10:37:25 -07:00
Denton Gentry
53f6c3f9f2 api.md: document preauthorized and tags fields
Updates https://github.com/tailscale/tailscale/issues/2120
Updates https://github.com/tailscale/tailscale/issues/4571
Updates https://github.com/tailscale/tailscale/issues/1369

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-05-17 09:25:13 -07:00
Brad Fitzpatrick
48d43134d7 cmd/tsshd: delete, leaving only forwarding docs
Updates #3802

Change-Id: I89d4d3d68d64af9bc7288a149b4b34f61884f5f4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-16 11:52:44 -07:00
Denton Gentry
afb3f62b01 scripts/installer.sh: add Xen Enterprise
Tested on a VM running Xen Enterprise 8.2.1.
https://xcp-ng.org/
Fixes https://github.com/tailscale/tailscale/issues/4655

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-05-16 08:37:39 -07:00
Brad Fitzpatrick
da601c23e1 ipn/ipnlocal: add missing place where we set the SSH atomic
This fixes the "tailscale up --authkey=... --ssh" path (or any "up"
path that used Start instead of EditPrefs) which wasn't setting the
bit.

Updates #3802

Change-Id: Ifca532ec58296fedcedb5582312dfee884367ed7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-14 12:47:28 -07:00
David Anderson
e1c1d47991 tsweb: memoize the string forms of HTTP response codes.
Saves 1-2 allocs per HTTP request after warmup, thanks to
avoiding strconv and fmt.

Signed-off-by: David Anderson <danderson@tailscale.com>
2022-05-13 14:25:17 -07:00
Tom
9343967317 wgengine/filter: preallocate some hot slices in MatchesFromFilterRules (#4672)
Profiling identified this as a fairly hot path for growing a slice.

Given this is only used in control & when a new packet filter is received, this shouldnt be hot in the client.
2022-05-13 13:56:53 -07:00
David Anderson
c48513b2be tsweb: support recording unabridged HTTP status codes as well.
Signed-off-by: David Anderson <danderson@tailscale.com>
2022-05-13 13:50:23 -07:00
Mihai Parparita
561f7be434 wgengine/magicsock: remove unused metric
We don't increment the metricRecvData anywhere, just the per-protocol
ones.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-05-13 11:15:56 -07:00
Mihai Parparita
dd5548771e util/clientmetric: add gauge_ name prefix when uploading names
The prefix is a signal to tsweb to treat this as a gauge metric when
generating the Prometheus version.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-05-12 09:52:56 -07:00
Mihai Parparita
86069874c9 net/tstun, wgengine: use correct type for counter metrics
We were marking them as gauges, but they are only ever incremented,
thus counter is more appropriate.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-05-12 09:30:50 -07:00
James Tucker
87b44aa311 go.mod: bump golang.org/x/sys for CVE-2022-29526
Signed-off-by: James Tucker <james@tailscale.com>
2022-05-10 17:57:51 -07:00
James Tucker
4bb7440094 cmd/tailscaled: use --statedir as documented
Enables the behavior described in the statepath flag, where if only
statedir is passed, then state is statedir/tailscaled.state.

Signed-off-by: James Tucker <james@tailscale.com>
2022-05-10 15:13:13 -07:00
Maisem Ali
6dae9e47f9 types/views: remove alloc in hot path
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-10 11:20:00 -07:00
Brad Fitzpatrick
4c75605e23 go.toolchain.rev: bump Tailscale Go toolchain
For bb6009ec7c

(The cherry-picked fix for golang/go#51776)

Change-Id: Ib4a67c0f82256c62989fbba2cc9c15b728313ebd
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-10 05:31:13 -07:00
Maisem Ali
395cb588b6 types/views: make SliceOf/MapOf panic if they see a pointer
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-09 19:49:31 -07:00
Maisem Ali
d04afc697c cmd/viewer,types/views: add support for views of maps
Updates #4635

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-09 19:49:31 -07:00
Maisem Ali
5cd56fe8d5 ssh/tailssh: exec into login when launching a shell
This has the added benefit of displaying the MOTD and reducing our
dependency on the DBus interface.

Fixes #4627
Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-09 19:17:52 -07:00
Maisem Ali
a253057fc3 ssh/tailssh: refactor incubator flags
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-09 19:17:52 -07:00
Joe Tsai
741ae9956e tstest/integration/vms: use hujson.Standardize instead of hujson.Unmarshal (#4520)
The hujson package transition to just being a pure AST
parser and formatter for HuJSON and not an unmarshaler.

Thus, parse HuJSON as such, convert it to JSON,
and then use the standard JSON unmarshaler.

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2022-05-06 14:16:10 -07:00
Maisem Ali
9f3ad40707 tailcfg: use cmd/viewer instead of cmd/cloner.
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-06 10:58:10 -07:00
Maisem Ali
fd99c54e10 tailcfg,all: change structs to []*dnstype.Resolver
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-06 10:58:10 -07:00
Maisem Ali
679415f3a8 tailcfg: move views into tailcfg_view.go
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-06 10:58:10 -07:00
Maisem Ali
c4e9739251 cmd/viewer: add codegen tool for Views
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-06 10:58:10 -07:00
Maisem Ali
e409e59a54 cmd/cloner,util/codegen: refactor cloner internals to allow reuse
Also run go generate again for Copyright updates.

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-06 10:58:10 -07:00
Mihai Parparita
025867fd07 util/clientmetric: switch to TestHooks struct for test-only functions (#4632)
Followup to 7966aed1e0 to pick up
review feedback that was accidentally left out.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-05-06 10:08:57 -07:00
Mihai Parparita
7966aed1e0 util/clientmetric: add test hooks and ResetLastDelta function
Necessary to force flushing of client metrics more aggressively in
dev/test mode.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-05-06 09:52:48 -07:00
Brad Fitzpatrick
35111061e9 wgengine/netstack, ipn/ipnlocal: serve http://100.100.100.100/
For future stuff.

Change-Id: I64615b8b2ab50b57e4eef1ca66fa72e3458cb4a9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-06 07:51:28 -07:00
Tom
d1d6ab068e net/dns, wgengine: implement DNS over TCP (#4598)
* net/dns, wgengine: implement DNS over TCP

Signed-off-by: Tom DNetto <tom@tailscale.com>

* wgengine/netstack: intercept only relevant port/protocols to quad-100

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-05-05 16:42:45 -07:00
Brad Fitzpatrick
c4f06ef7be client/tailscale: fix ExpandSNIName on non-default LocalClient
It was using a mix.

Found by @maisem.

Change-Id: Ieb79d78608474ac13c2f44e0f3d8997a5665eb13
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-05 12:22:38 -07:00
Brad Fitzpatrick
46cb9d98a3 api.md: update GET tailnet key detail docs to show preauthorized, tags
Fixes #4571

Change-Id: If81471d0d8cb2f659736991ad8612aed2efc174e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-05 12:09:28 -07:00
Brad Fitzpatrick
c1445155ef ssh/tailssh: handle Control-C during hold-and-delegate prompt
Fixes #4549

Change-Id: Iafc61af5e08cd03564d39cf667e940b2417714cc
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-05 11:47:08 -07:00
James Tucker
f9e86e64b7 *: use WireGuard where logged, printed or named
Signed-off-by: James Tucker <james@tailscale.com>
2022-05-04 13:36:05 -07:00
Brad Fitzpatrick
2d1849a7b9 tsweb: remove JSONHandlerFunc
It's unused and we've decided it's not what we want.

Change-Id: I425a0104e8869630b498a0adfd0f455876d6f92b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-04 11:52:07 -07:00
Charlotte Brandhorst-Satzkorn
7ee3068f9d words: after a Series of discussions, Bees should be included (#4606)
There has been a lot of talk about Bees at Tailscale recently, and
naturally, with it being Tailscale, we thought to ourselves:

Do Bees have a tail and/or scales?

Tailscale has a long track record of scientific rigor around the
validity of the inclusions on the tails and scales list, and this time
will be no exception.

Our research has found that Bees, in particular the Honey Bee, produces
wax scales on their abdomens and thus should be included. As for tails;

'Stabby-tails' - Tailscale Employee, 2022

No further justification needed, it will be included.

This change includes Bee in both tails.txt and scales.txt.

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2022-05-04 09:20:23 -04:00
Brad Fitzpatrick
3e1f2d01f7 ipn/ipnlocal: move Ping method from IPN bus to LocalBackend (HTTP)
Change-Id: I61759f1dae8d9d446353db54c8b1e13bfffb3287
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-03 15:59:19 -07:00
Maisem Ali
c60cbca371 control/controlclient: store netinfo and hostinfo separately
Currently, when SetNetInfo is called it sets the value on
hostinfo.NetInfo. However, when SetHostInfo is called it overwrites the
hostinfo field which may mean it also clears out the NetInfo it had just
received.
This commit stores NetInfo separately and combines it into Hostinfo as
needed so that control is always notified of the latest values.

Also, remove unused copies of Hostinfo from ipn.Status and
controlclient.Auto.

Updates #tailscale/corp#4824 (maybe fixes)

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-05-03 15:33:01 -07:00
James Tucker
ae483d3446 wgengine, net/packet, cmd/tailscale: add ICMP echo
Updates tailscale/corp#754

Signed-off-by: James Tucker <james@tailscale.com>
2022-05-03 13:03:45 -07:00
Brad Fitzpatrick
66f9292835 client/tailscale: update Client API a bit
Change-Id: I81aa29a8b042a247eac1941038f5d90259569941
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-03 11:30:57 -07:00
Brad Fitzpatrick
512573598a tailcfg: remove some documented DebugFlags that no longer exist
Update tailscale/corp#5007

Change-Id: I3ce5b1c4cd367bae769a5f5a301925a2dac1b3a6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-05-03 08:28:41 -07:00
Tom DNetto
2a0b5c21d2 net/dns/{., resolver}, wgengine: fix goroutine leak on shutdown
Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-05-02 10:42:06 -07:00
Tom DNetto
7f45734663 assorted: documentation and readability fixes
This were intended to be pushed to #4408, but in my excitement I
forgot to git push :/ better late than never.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-04-30 18:42:19 -07:00
Tom DNetto
9e77660931 net/tstun,wgengine/{.,netstack}: handle UDP magicDNS traffic in netstack
This change wires netstack with a hook for traffic coming from the host
into the tun, allowing interception and handling of traffic to quad-100.

With this hook wired, magicDNS queries over UDP are now handled within
netstack. The existing logic in wgengine to handle magicDNS remains for now,
but its hook operates after the netstack hook so the netstack implementation
takes precedence. This is done in case we need to support platforms with
netstack longer than expected.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-04-30 10:18:59 -07:00
Tom DNetto
dc71d3559f net/tstun,wgengine: split PreFilterOut into multiple hooks
A subsequent commit implements handling of magicDNS traffic via netstack.
Implementing this requires a hook for traffic originating from the host and
hitting the tun, so we make another hook to support this.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-04-30 10:18:59 -07:00
Tom DNetto
9dee6adfab cmd/tailscaled,ipn/ipnlocal,wgengine/...: pass dns.Manager into netstack
Needed for a following commit which moves magicDNS handling into
netstack.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-04-30 10:18:59 -07:00
Tom DNetto
5b85f848dd net/dns,net/dns/resolver: refactor channels/magicDNS out of Resolver
Moves magicDNS-specific handling out of Resolver & into dns.Manager. This
greatly simplifies the Resolver to solely issuing queries and returning
responses, without channels.

Enforcement of max number of in-flight magicDNS queries, assembly of
synthetic UDP datagrams, and integration with wgengine for
recieving/responding to magicDNS traffic is now entirely in Manager.
This path is being kept around, but ultimately aims to be deleted and
replaced with a netstack-based path.

This commit is part of a series to implement magicDNS using netstack.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-04-30 10:18:59 -07:00
Brad Fitzpatrick
a54671529b client/tailscale: move API client for the control admin API
This was work done Nov-Dec 2020 by @c22wen and @chungdaniel.

This is just moving it to another repo.

Co-Authored-By: Christina Wen <37028905+c22wen@users.noreply.github.com>
Co-Authored-By: Christina Wen <christina@tailscale.com>
Co-Authored-By: Daniel Chung <chungdaniel@users.noreply.github.com>
Co-Authored-By: Daniel Chung <daniel@tailscale.com>

Change-Id: I6da3b05b972b54771f796b5be82de5aa463635ca
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-30 09:05:26 -07:00
Brad Fitzpatrick
e3619b890c client/tailscale: rename tailscale.go -> localclient.go
In prep for other stuff.

Change-Id: I82c24946d062d668cab48ca6749776b6ae7025ac
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-29 19:50:40 -07:00
Maisem Ali
3012a2e1ca ssh/tailssh,ipn/ipnlocal: terminate any active sessions on up --ssh=false
Currently the ssh session isn't terminated cleanly, instead the packets
are just are no longer routed to the in-proc SSH server. This makes it
so that clients get a disconnection when the `RunSSH` pref changes to
`false`.

Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-29 16:08:27 -07:00
Mihai Parparita
2ec371fe8b ipn: remove FakeExpireAfter Backend function
No callers remain (last one was removed with
tailscale/corp@1c095ae08f), and it's
pretty esoteric.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-04-29 15:34:53 -07:00
Aaron Klotz
d915e0054c cmd/tailscaled: change Windows service shutdown and add optional event logging
Once a stop request is received and the service updates its status to `svc.StopPending`,
it should continue running *until the shutdown sequence is complete*, and then
return out of `(*ipnService).Execute`, which automatically sends a `svc.Stopped`
notification to Windows.

To make this happen, I changed the loop so that it runs until `doneCh` is
closed, and then returns. I also removed a spurious `svc.StopPending` notification
that the Windows Service Control Manager might be interpreting as a request for
more time to shut down.

Finally, I added some optional logging that sends a record of service notifications
to the Windows event log, allowing us to more easily correlate with any Service
Control Manager errors that are sent to the same log.

Change-Id: I5b596122e5e89c4c655fe747a612a52cb4e8f1e0
Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2022-04-29 15:13:11 -07:00
Mihai Parparita
316523cc1e ipn: remove enforceDefaults option from PrefsFromBytes
The Mac client was using it, but it had the effect of the `RouteAll`
("Use Tailscale subnets") pref always being enabled at startup,
regardless of the persisted value.

enforceDefaults was added to handle cases from ~2 years ago where
we ended up with persisted `"RouteAll": false` values in the keychain,
but that should no longer be a concern. New users will get the default
of it being enabled via `NewPrefs`.

There will be a corresponding Mac client change to stop passing in
enforceDefaults.

For #3962

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-04-29 14:08:26 -07:00
Brad Fitzpatrick
87ba528ae0 client/tailscale: move/copy all package funcs to new LocalClient type
Remove all global variables, and clean up tsnet and cmd/tailscale's usage.

This is in prep for using this package for the web API too (it has the
best package name).

RELNOTE=tailscale.com/client/tailscale package refactored w/ LocalClient type

Change-Id: Iba9f162fff0c520a09d1d4bd8862f5c5acc9d7cd
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-29 13:57:52 -07:00
Brad Fitzpatrick
373176ea54 util/codegen: format generated code with goimports, not gofmt
goimports is a superset of gofmt that also groups imports.
(the goimports tool also adds/removes imports as needed, but that
part is disabled here)

Change-Id: Iacf0408dfd9497f4ed3da4fa50e165359ce38498
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-29 13:35:45 -07:00
Brad Fitzpatrick
6bed781259 all: gofmt all
Well, goimports actually (which adds the normal import grouping order we do)

Change-Id: I0ce1b1c03185f3741aad67c14a7ec91a838de389
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-29 13:06:04 -07:00
Brad Fitzpatrick
deb56f276e Revert "api: document preauthorized auth keys"
This reverts commit dd6472d4e8.

Reason: it appears I was just really really wrong or confused.

We added it to the old internal API used by the website instead,
not to the "v2" API.

Updates #2120
Updates #4571

Change-Id: I744a72b9193aafa7b526fd760add52148a377e83
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-29 08:47:18 -07:00
Mihai Parparita
cfe68d0a86 safesocket: log warning when running sandboxed Mac binary as root
It won't work, provide a clue in the error output.

Fixes #3063

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-04-28 16:22:19 -07:00
Brad Fitzpatrick
6f5b91c94c go.mod: tidy
Change-Id: If3b5fe42e7dd7858dcce02a3a24a5e59736815a2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-28 14:32:43 -07:00
Brad Fitzpatrick
2336c73d4d go.mod: tidy
Change-Id: If3b5fe42e7dd7858dcce02a3a24a5e59736815a2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-28 14:29:53 -07:00
James Tucker
96fec4b969 net/tshttpproxy: synology: pick proxy by scheme
This updates the fix from #4562 to pick the proxy based on the request
scheme.

Updates #4395, #2605, #4562
Signed-off-by: James Tucker <james@tailscale.com>
2022-04-28 11:56:37 -07:00
Maisem Ali
eff6a404a6 net/tshttpproxy: use http as the scheme for proxies
Currently we try to use `https://` when we see `https_host`, however
that doesn't work and results in errors like `Received error: fetch
control key: Get "https://controlplane.tailscale.com/key?v=32":
proxyconnect tcp: tls: first record does not look like a TLS handshake`

This indiciates that we are trying to do a HTTPS request to a HTTP
server. Googling suggests that the standard is to use `http` regardless
of `https` or `http` proxy

Updates #4395, #2605

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-28 10:45:33 -07:00
Walter Poupore
71d401cc4e api.md: remove descriptions from TOC (#4561) 2022-04-28 09:36:26 -07:00
Brad Fitzpatrick
1237000efe control/controlhttp: don't assume port 80 upgrade response will work
Just because we get an HTTP upgrade response over port 80, don't
assume we'll be able to do bi-di Noise over it. There might be a MITM
corp proxy or anti-virus/firewall interfering. Do a bit more work to
validate the connection before proceeding to give up on the TLS port
443 dial.

Updates #4557 (probably fixes)

Change-Id: I0e1bcc195af21ad3d360ffe79daead730dfd86f1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-28 09:14:41 -07:00
Brad Fitzpatrick
488e63979e api.md: document new ACL validate mode
Updates tailscale/corp#4932

Change-Id: Ie176ee79595c3b56d3376f1d81a441f9272e6ed4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-27 15:58:37 -07:00
Maisem Ali
5a1ef1bbb9 net/tsdial: add SystemDial as a wrapper on netns.Dial
The connections returned from SystemDial are automatically closed when
there is a major link change.

Also plumb through the dialer to the noise client so that connections
are auto-reset when moving from cellular to WiFi etc.

Updates #3363

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-27 12:02:36 -07:00
Brad Fitzpatrick
e38d3dfc76 control/controlhttp: start port 443 fallback sooner if 80's stuck
Fixes #4544

Change-Id: I39877e71915ad48c6668351c45cd8e33e2f5dbae
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-27 10:34:53 -07:00
Maisem Ali
637cc1b5fc ipn/ipnlocal/peerapi: add endpoint to list local interfaces
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-27 06:44:58 -07:00
James Tucker
1aa75b1c9e wgengine/netstack: always set TCP keepalive
Setting keepalive ensures that idle connections will eventually be
closed. In userspace mode, any application configured TCP keepalive is
effectively swallowed by the host kernel, and is not easy to detect.
Failure to close connections when a peer tailscaled goes offline or
restarts may result in an otherwise indefinite connection for any
protocol endpoint that does not initiate new traffic.

This patch does not take any new opinion on a sensible default for the
keepalive timers, though as noted in the TODO, doing so likely deserves
further consideration.

Update #4522

Signed-off-by: James Tucker <james@tailscale.com>
2022-04-26 19:29:08 -07:00
Brad Fitzpatrick
adcb7e59d2 control/controlclient: fix log print with always-empty key
In debugging #4541, I noticed this log print was always empty.
The value printed was always zero at this point.

Updates #4541

Change-Id: I0eef60c32717c293c1c853879446be65d9b2cef6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-26 19:27:20 -07:00
Brad Fitzpatrick
c88506caa6 ipn/ipnlocal: add Wake-on-LAN function to peerapi
No CLI support yet. Just the curl'able version if you know the peerapi
port. (like via a TSMP ping)

Updates #306

Change-Id: I0662ba6530f7ab58d0ddb24e3664167fcd1c4bcf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-26 15:20:59 -07:00
Brad Fitzpatrick
3f7cc3563f ipn: always treat login.tailscale.com as controlplane.tailscale.com
Like 888e50e1, but more aggressive.

Updates #4538 (likely fixes)
Updates #3488

Change-Id: I3924eee9110e47bdba926ce12954253bf2413040
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-26 15:16:32 -07:00
Brad Fitzpatrick
c6c752cf64 net/tshttpproxy: fix typo
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-26 08:14:50 -07:00
Brad Fitzpatrick
50eb8c5add cmd/tailscale: mostly fix 'tailscale ssh' on macOS (sandbox)
Still a little wonky, though. See the tcsetattr error and inability to
hit Ctrl-D, for instance:

    bradfitz@laptop ~ % tailscale.app ssh foo@bar
    tcsetattr: Operation not permitted
    # Authentication checked with Tailscale SSH.
    # Time since last authentication: 1h13m22s
    foo@bar:~$ ^D
    ^D
    ^D

Updates #4518
Updates #4529

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-26 07:40:42 -07:00
Brad Fitzpatrick
48e5f4ff88 cmd/tailscale/cli: add 'debug stat' subcommand
For debugging what's visible inside the macOS sandbox.

But could also be useful for giving users portable commands
during debugging without worrying about which OS they're on.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-26 07:30:08 -07:00
Brad Fitzpatrick
21413392cf safesocket: fix CLI on standalone mac GUI build
Tested three macOS Tailscale daemons:

- App Store (Network Extension)
- Standalone (macsys)
- tailscaled

And two types of local IPC each:

- IPN
- HTTP

And two CLI modes:

- sandboxed (running the GUI binary as the CLI; normal way)
- open source CLI hitting GUI (with #4525)

Bonus: simplifies the code.

Fixes tailscale/corp#4559

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-25 21:47:00 -07:00
Brad Fitzpatrick
3601b43530 ipn: add IPCVersion override func
I've done this a handful of times in the past and again today.
Time to make it a supported thing for the future.

Used while debugging tailscale/corp#4559 (macsys CLI issues)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-25 20:51:05 -07:00
James Tucker
928d1fddd2 cmd/tailscale: s/-authkey/-auth-key/ in help text
Signed-off-by: James Tucker <james@tailscale.com>
2022-04-25 17:30:50 -07:00
Tom DNetto
5fb8e01a8b net/dns/resolver: add metric for number of truncated dns packets
Updates #2067

This should help us determine if more robust control of edns parameters
+ implementing answer truncation is warranted, given its likely complexity.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-04-25 13:05:28 -07:00
Maisem Ali
80ba161c40 wgengine/monitor: do not ignore changes to pdp_ip*
One current theory (among other things) on battery consumption is that
magicsock is resorting to using the IPv6 over LTE even on WiFi.
One thing that could explain this is that we do not get link change updates
for the LTE modem as we ignore them in this list.
This commit makes us not ignore changes to `pdp_ip` as a test.

Updates #3363

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-25 12:17:00 -07:00
Maisem Ali
1a19aed410 ipn/ipnlocal: do not initialize peer api listeners when shutting down
Updates tailscale/corp#4824

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-25 11:08:03 -07:00
Brad Fitzpatrick
e97209c6bf net/dns: add tailscaled-on-macOS DNS OSConfigurator
This populates DNS suffixes ("ts.net", etc) in /etc/resolver/* files
to point to 100.100.100.100 so MagicDNS works.

It also sets search domains.

Updates #4276

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-23 20:43:41 -07:00
Maisem Ali
bbca2c78cb tsnet: fix mem.Store check for normal nodes
There was a typo in the check it was doing `!ok` instead of `ok`, this
restructures it a bit to read better.

Fixes #4506

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-22 23:52:16 -07:00
Denton Gentry
d819bb3bb0 VERSION.txt: This is 1.25.0
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-04-22 13:26:24 -07:00
Maisem Ali
2265587d38 wgengine/{,magicsock}: add metrics for rebinds and restuns
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-22 11:55:46 -07:00
Tom DNetto
78fededaa5 net/dns/resolver: support magic resolution of via-<siteid>.<ip4> domains
Updates #3616

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-04-22 09:21:35 -07:00
Brad Fitzpatrick
910ae68e0b util/mak: move tailssh's mapSet into a new package for reuse elsewhere
Change-Id: Idfe95db82275fd2be6ca88f245830731a0d5aecf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-21 21:20:10 -07:00
James Tucker
c2eff20008 ssh/tailssh: avoid user ssh configuration in tests
Signed-off-by: James Tucker <james@tailscale.com>
2022-04-21 19:17:34 -07:00
James Tucker
700bd37730 tshttpproxy: support synology proxy configuration
Fixes #4395
Fixes #2605

Signed-off-by: James Tucker <james@tailscale.com>
2022-04-21 18:39:00 -07:00
Maisem Ali
90b5f6286c cmd/tailscale: use double quotes in the ssh subcommands
Single-quote escaping is insufficient apparently.

Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-21 17:43:04 -07:00
Maisem Ali
db70774685 cmd/tailscale/cli: do not use syscall.Exec from macOS sandbox
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-21 17:07:18 -07:00
Tom DNetto
37c94c07cd shell.nix: update go toolchain
Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-04-21 15:47:34 -07:00
David Anderson
a364bf2b62 ssh/tailssh: various typo fixes, clarifications.
Signed-off-by: David Anderson <danderson@tailscale.com>
2022-04-21 15:04:13 -07:00
Brad Fitzpatrick
c994eba763 ssh/tailssh: simplify matchRule with Reject rules
Updates #3802

Change-Id: I59fe111eef5ac8abbcbcec922e293712a65a4830
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-21 15:04:02 -07:00
Maisem Ali
31094d557b ssh/tailssh: chmod the auth socket to be only user accessible
Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-21 14:49:22 -07:00
Maisem Ali
337c77964b ssh/tailssh: set groups and gid in the incubated process
Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-21 14:48:34 -07:00
Brad Fitzpatrick
8ac4d52b59 ssh/tailssh: filter accepted environment variables
Noted by @danderson

Updates #3802

Change-Id: Iac70717ed57f11726209ac1ea93ddc6696605f94
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-21 14:44:46 -07:00
Brad Fitzpatrick
89832c1a95 tailcfg: fix typo in SessionDuration field name
Noted by @danderson.

Updates #3802

Change-Id: Ide15f3f28e30f6abb5c94d7dcd218bd9482752a0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-04-21 14:19:58 -07:00
Maisem Ali
695f8a1d7e ssh/tailssh: add support for sftp
Updates #3802

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-04-21 10:52:22 -07:00
283 changed files with 19640 additions and 4343 deletions

View File

@@ -1,4 +1,4 @@
name: Android-Cross
name: Wasm-Cross
on:
push:
@@ -25,17 +25,11 @@ jobs:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Android build cmd
- name: Wasm client build
env:
GOOS: android
GOARCH: amd64
run: go build ./cmd/...
- name: Android build tests (does not run tests)
env:
GOOS: android
GOARCH: amd64
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -run '^$' -c ); done
GOOS: js
GOARCH: wasm
run: go build ./cmd/tsconnect/wasm
- uses: k0kubun/action-slack@v2.0.0
with:

View File

@@ -43,6 +43,17 @@ jobs:
# TODO(raggi): add a go version here.
key: ${{ runner.os }}-go-2-race-${{ hashFiles('**/go.sum') }}
- name: Print toolchain details
run: gcc -v
# There is currently an issue in the race detector in Go on Windows when
# used with a newer version of GCC.
# See https://github.com/tailscale/tailscale/issues/4926.
- name: Downgrade MinGW
shell: bash
run: |
choco install mingw --version 10.2.0 --allow-downgrade
- name: Test with -race flag
# Don't use -bench=. -benchtime=1x.
# Somewhere in the layers (powershell?)

1
ALPINE.txt Normal file
View File

@@ -0,0 +1 @@
3.16

View File

@@ -39,6 +39,18 @@ WORKDIR /go/src/tailscale
COPY go.mod go.sum ./
RUN go mod download
# Pre-build some stuff before the following COPY line invalidates the Docker cache.
RUN go install \
github.com/aws/aws-sdk-go-v2/aws \
github.com/aws/aws-sdk-go-v2/config \
gvisor.dev/gvisor/pkg/tcpip/adapters/gonet \
gvisor.dev/gvisor/pkg/tcpip/stack \
golang.org/x/crypto/ssh \
golang.org/x/crypto/acme \
nhooyr.io/websocket \
github.com/mdlayher/netlink \
golang.zx2c4.com/wireguard/device
COPY . .
# see build_docker.sh
@@ -56,5 +68,8 @@ RUN GOARCH=$TARGETARCH go install -ldflags="\
-X tailscale.com/version.GitCommit=$VERSION_GIT_HASH" \
-v ./cmd/tailscale ./cmd/tailscaled
FROM ghcr.io/tailscale/alpine-base:3.14
FROM alpine:3.16
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
COPY --from=build-env /go/bin/* /usr/local/bin/
COPY --from=build-env /go/src/tailscale/docs/k8s/run.sh /usr/local/bin/

View File

@@ -2,5 +2,5 @@
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
FROM alpine:3.15
FROM alpine:3.16
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables

View File

@@ -1 +1 @@
1.23.0
1.29.0

151
api.md
View File

@@ -22,9 +22,9 @@ Currently based on {some authentication method}. Visit the [admin panel](https:/
* **[Tailnets](#tailnet)**
- ACLs
- [GET tailnet ACL](#tailnet-acl-get)
- [POST tailnet ACL](#tailnet-acl-post): set ACL for a tailnet
- [POST tailnet ACL preview](#tailnet-acl-preview-post): preview rule matches on an ACL for a resource
- [POST tailnet ACL validate](#tailnet-acl-validate-post): run validation tests against the tailnet's existing ACL
- [POST tailnet ACL](#tailnet-acl-post)
- [POST tailnet ACL preview](#tailnet-acl-preview-post)
- [POST tailnet ACL validate](#tailnet-acl-validate-post)
- [Devices](#tailnet-devices)
- [GET tailnet devices](#tailnet-devices-get)
- [Keys](#tailnet-keys)
@@ -42,12 +42,12 @@ Currently based on {some authentication method}. Visit the [admin panel](https:/
## Device
<!-- TODO: description about what devices are -->
Each Tailscale-connected device has a globally-unique identifier number which we refer as the "deviceID" or sometimes, just "id".
Each Tailscale-connected device has a globally-unique identifier number which we refer as the "deviceID" or sometimes, just "id".
You can use the deviceID to specify operations on a specific device, like retrieving its subnet routes.
To find the deviceID of a particular device, you can use the ["GET /devices"](#getdevices) API call and generate a list of devices on your network.
To find the deviceID of a particular device, you can use the ["GET /devices"](#getdevices) API call and generate a list of devices on your network.
Find the device you're looking for and get the "id" field.
This is your deviceID.
This is your deviceID.
<a name=device-get></a>
@@ -60,7 +60,7 @@ Use the `fields` query parameter to explicitly indicate which fields are returne
##### Parameters
##### Query Parameters
`fields` - Controls which fields will be included in the returned response.
Currently, supported options are:
Currently, supported options are:
* `all`: returns all fields in the response.
* `default`: return all fields except:
* `enabledRoutes`
@@ -72,7 +72,7 @@ If more than one option is indicated, then the union is used.
For example, for `fields=default,all`, all fields are returned.
If the `fields` parameter is not provided, then the default option is used.
##### Example
##### Example
```
GET /api/v2/device/12345
curl 'https://api.tailscale.com/api/v2/device/12345?fields=all' \
@@ -102,10 +102,10 @@ Response
"nodeKey":"nodekey:user1-node-key",
"blocksIncomingConnections":false,
"enabledRoutes":[
],
"advertisedRoutes":[
],
"clientConnectivity": {
"endpoints":[
@@ -141,7 +141,7 @@ Response
<a name=device-delete></a>
#### `DELETE /api/v2/device/:deviceID` - deletes the device from its tailnet
Deletes the provided device from its tailnet.
Deletes the provided device from its tailnet.
The device must belong to the user's tailnet.
Deleting shared/external devices is not supported.
Supply the device of interest in the path using its ID.
@@ -159,7 +159,7 @@ curl -X DELETE 'https://api.tailscale.com/api/v2/device/12345' \
Response
If successful, the response should be empty:
If successful, the response should be empty:
```
< HTTP/1.1 200 OK
...
@@ -167,7 +167,7 @@ If successful, the response should be empty:
* Closing connection 0
```
If the device is not owned by your tailnet:
If the device is not owned by your tailnet:
```
< HTTP/1.1 501 Not Implemented
...
@@ -317,14 +317,9 @@ Allows for updating properties on the device key.
- Provide `false` to 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.
`preauthorized`
- If `true`, don't require machine authorization (if enabled on the tailnet)
```
{
"keyExpiryDisabled": true,
"preauthorized": true
"keyExpiryDisabled": true
}
```
@@ -339,8 +334,8 @@ curl 'https://api.tailscale.com/api/v2/device/11055/key' \
The response is 2xx on success. The response body is currently an empty JSON
object.
## Tailnet
A tailnet is the name of your Tailscale network.
## Tailnet
A tailnet is the name of your Tailscale network.
You can find it in the top left corner of the [Admin Panel](https://login.tailscale.com/admin) beside the Tailscale logo.
@@ -620,7 +615,12 @@ Response:
#### `POST /api/v2/tailnet/:tailnet/acl/validate` - run validation tests against the tailnet's active ACL
Runs the provided ACL tests against the tailnet's existing ACL. This endpoint does not modify the ACL in any way.
This endpoint works in one of two modes:
1. with a request body that's a JSON array, the body is interpreted as ACL tests to run against the domain's current ACLs.
2. with a request body that's a JSON object, the body is interpreted as a hypothetical new JSON (HuJSON) body with new ACLs, including any tests.
In either case, this endpoint does not modify the ACL in any way.
##### Parameters
@@ -630,7 +630,7 @@ The POST body should be a JSON formatted array of ACL Tests.
See https://tailscale.com/kb/1018/acls for more information on the format of ACL tests.
##### Example
##### Example with tests
```
POST /api/v2/tailnet/example.com/acl/validate
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
@@ -641,11 +641,28 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
]'
```
Response:
If all the tests pass, the response will be empty, with an http status code of 200.
##### Example with an ACL body
```
POST /api/v2/tailnet/example.com/acl/validate
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
-u "tskey-yourapikey123:" \
--data-binary '
{
"ACLs": [
{ "Action": "accept", "src": ["100.105.106.107"], "dst": ["1.2.3.4:*"] },
],
"Tests", [
{"src": "100.105.106.107", "allow": ["1.2.3.4:80"]}
],
}'
```
Response:
The HTTP status code will be 200 if the request was well formed and there were no server errors, even in the case of failing tests or an invalid ACL. Look at the response body to determine whether there was a problem within your ACL or tests.
If there's a problem, the response body will be a JSON object with a non-empty `message` property and optionally additional details in `data`:
Failed test error response:
A 400 http status code and the errors in the response body.
```
{
"message":"test(s) failed",
@@ -658,6 +675,8 @@ A 400 http status code and the errors in the response body.
}
```
An empty body or a JSON object with no `message` is returned on success.
<a name=tailnet-devices></a>
### Devices
@@ -665,7 +684,7 @@ A 400 http status code and the errors in the response body.
<a name=tailnet-devices-get></a>
#### <a name="getdevices"></a> `GET /api/v2/tailnet/:tailnet/devices` - list the devices for a tailnet
Lists the devices in a tailnet.
Lists the devices in a tailnet.
Supply the tailnet of interest in the path.
Use the `fields` query parameter to explicitly indicate which fields are returned.
@@ -674,7 +693,7 @@ Use the `fields` query parameter to explicitly indicate which fields are returne
###### Query Parameters
`fields` - Controls which fields will be included in the returned response.
Currently, supported options are:
Currently, supported options are:
* `all`: Returns all fields in the response.
* `default`: return all fields except:
* `enabledRoutes`
@@ -694,7 +713,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/devices' \
-u "tskey-yourapikey123:"
```
Response
Response
```
{
"devices":[
@@ -796,11 +815,15 @@ Supply the tailnet in the path.
`capabilities` - A mapping of resources to permissible actions.
```
{
"capabilities": {
"capabilities": {
"devices": {
"create": {
"reusable": false,
"ephemeral": false
"ephemeral": false,
"preauthorized": false,
"tags": [
"tag:example"
]
}
}
}
@@ -820,11 +843,13 @@ The full key can no longer be retrieved by the server.
```
echo '{
"capabilities": {
"capabilities": {
"devices": {
"create": {
"reusable": false,
"ephemeral": false
"ephemeral": false,
"preauthorized": false,
"tags": [ "tag:example" ]
}
}
}
@@ -840,7 +865,7 @@ Response:
"key": "tskey-k123456CNTRL-abcdefghijklmnopqrstuvwxyz",
"created": "2021-12-09T23:22:39Z",
"expires": "2022-03-09T23:22:39Z",
"capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false}}}
"capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false, "preauthorized": false, "tags": [ "tag:example" ]}}}
}
```
@@ -870,10 +895,22 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k123456CNTRL' \
Response:
```
{
"id": "k123456CNTRL",
"created": "2021-12-09T22:13:53Z",
"expires": "2022-03-09T22:13:53Z",
"capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false}}}
"id": "k123456CNTRL",
"created": "2022-05-05T18:55:44Z",
"expires": "2022-08-03T18:55:44Z",
"capabilities": {
"devices": {
"create": {
"reusable": false,
"ephemeral": true,
"preauthorized": false,
"tags": [
"tag:bar",
"tag:foo"
]
}
}
}
}
```
@@ -904,13 +941,13 @@ curl -X DELETE 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k12345
<a name=tailnet-dns-nameservers-get></a>
#### `GET /api/v2/tailnet/:tailnet/dns/nameservers` - list the DNS nameservers for a tailnet
Lists the DNS nameservers for a tailnet.
Lists the DNS nameservers for a tailnet.
Supply the tailnet of interest in the path.
##### Parameters
No parameters.
##### Example
##### Example
```
GET /api/v2/tailnet/example.com/dns/nameservers
@@ -918,7 +955,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \
-u "tskey-yourapikey123:"
```
Response
Response
```
{
"dns": ["8.8.8.8"],
@@ -928,7 +965,7 @@ Response
<a name=tailnet-dns-nameservers-post></a>
#### `POST /api/v2/tailnet/:tailnet/dns/nameservers` - replaces the list of DNS nameservers for a tailnet
Replaces the list of DNS nameservers for the given tailnet with the list supplied by the user.
Replaces the list of DNS nameservers for the given tailnet with the list supplied by the user.
Supply the tailnet of interest in the path.
Note that changing the list of DNS nameservers may also affect the status of MagicDNS (if MagicDNS is on).
@@ -942,7 +979,7 @@ Note that changing the list of DNS nameservers may also affect the status of Mag
```
##### Returns
Returns the new list of nameservers and the status of MagicDNS.
Returns the new list of nameservers and the status of MagicDNS.
If all nameservers have been removed, MagicDNS will be automatically disabled (until explicitly turned back on by the user).
@@ -986,31 +1023,31 @@ Retrieves the DNS preferences that are currently set for the given tailnet.
Supply the tailnet of interest in the path.
##### Parameters
No parameters.
No parameters.
##### Example
```
GET /api/v2/tailnet/example.com/dns/preferences
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences' \
-u "tskey-yourapikey123:"
-u "tskey-yourapikey123:"
```
Response:
```
{
"magicDNS":false,
"magicDNS":false,
}
```
<a name=tailnet-dns-preferences-post></a>
#### `POST /api/v2/tailnet/:tailnet/dns/preferences` - replaces the DNS preferences for a tailnet
#### `POST /api/v2/tailnet/:tailnet/dns/preferences` - replaces the DNS preferences for a tailnet
Replaces the DNS preferences for a tailnet, specifically, the MagicDNS setting.
Note that MagicDNS is dependent on DNS servers.
Note that MagicDNS is dependent on DNS servers.
If there is at least one DNS server, then MagicDNS can be enabled.
If there is at least one DNS server, then MagicDNS can be enabled.
Otherwise, it returns an error.
Note that removing all nameservers will turn off MagicDNS.
Note that removing all nameservers will turn off MagicDNS.
To reenable it, nameservers must be added back, and MagicDNS must be explicitly turned on.
##### Parameters
@@ -1041,7 +1078,7 @@ If there are no DNS servers, it returns an error message:
}
```
If there are DNS servers:
If there are DNS servers:
```
{
"magicDNS":true,
@@ -1050,8 +1087,8 @@ If there are DNS servers:
<a name=tailnet-dns-searchpaths-get></a>
#### `GET /api/v2/tailnet/:tailnet/dns/searchpaths` - retrieves the search paths for a tailnet
Retrieves the list of search paths that is currently set for the given tailnet.
#### `GET /api/v2/tailnet/:tailnet/dns/searchpaths` - retrieves the search paths for a tailnet
Retrieves the list of search paths that is currently set for the given tailnet.
Supply the tailnet of interest in the path.
@@ -1062,7 +1099,7 @@ No parameters.
```
GET /api/v2/tailnet/example.com/dns/searchpaths
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths' \
-u "tskey-yourapikey123:"
-u "tskey-yourapikey123:"
```
Response:
@@ -1074,7 +1111,7 @@ Response:
<a name=tailnet-dns-searchpaths-post></a>
#### `POST /api/v2/tailnet/:tailnet/dns/searchpaths` - replaces the search paths for a tailnet
#### `POST /api/v2/tailnet/:tailnet/dns/searchpaths` - replaces the search paths for a tailnet
Replaces the list of searchpaths with the list supplied by the user and returns an error otherwise.
##### Parameters
@@ -1083,7 +1120,7 @@ Replaces the list of searchpaths with the list supplied by the user and returns
`searchPaths` - A list of searchpaths in JSON.
```
{
"searchPaths: ["user1.example.com", "user2.example.com"]
"searchPaths": ["user1.example.com", "user2.example.com"]
}
```

View File

@@ -25,14 +25,14 @@ export PATH=$PWD/tool:$PATH
eval $(./build_dist.sh shellvars)
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
DEFAULT_REPOS="tailscale/tailscale,ghcr.io/tailscale/tailscale"
DEFAULT_BASE="ghcr.io/tailscale/alpine-base:3.14"
DEFAULT_BASE="ghcr.io/tailscale/alpine-base:3.16"
PUSH="${PUSH:-false}"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
TAGS="${TAGS:-${DEFAULT_TAGS}}"
BASE="${BASE:-${DEFAULT_BASE}}"
go run github.com/tailscale/mkctr@latest \
go run github.com/tailscale/mkctr \
--gopaths="\
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled" \
@@ -40,7 +40,9 @@ go run github.com/tailscale/mkctr@latest \
-X tailscale.com/version.Long=${VERSION_LONG} \
-X tailscale.com/version.Short=${VERSION_SHORT} \
-X tailscale.com/version.GitCommit=${VERSION_GIT_HASH}" \
--files="docs/k8s/run.sh:/tailscale/run.sh" \
--base="${BASE}" \
--tags="${TAGS}" \
--repos="${REPOS}" \
--push="${PUSH}"
--push="${PUSH}" \
/bin/sh /tailscale/run.sh

478
client/tailscale/acl.go Normal file
View File

@@ -0,0 +1,478 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.18
// +build go1.18
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"inet.af/netaddr"
)
// ACLRow defines a rule that grants access by a set of users or groups to a set
// of servers and ports.
// Only one of Src/Dst or Users/Ports may be specified.
type ACLRow struct {
Action string `json:"action,omitempty"` // valid values: "accept"
Users []string `json:"users,omitempty"` // old name for src
Ports []string `json:"ports,omitempty"` // old name for dst
Src []string `json:"src,omitempty"`
Dst []string `json:"dst,omitempty"`
}
// ACLTest defines a test for your ACLs to prevent accidental exposure or
// revoking of access to key servers and ports. Only one of Src or User may be
// specified, and only one of Allow/Accept may be specified.
type ACLTest struct {
Src string `json:"src,omitempty"` // source
User string `json:"user,omitempty"` // old name for source
Accept []string `json:"accept,omitempty"` // expected destination ip:port that user can access
Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access
Allow []string `json:"allow,omitempty"` // old name for accept
}
// ACLDetails contains all the details for an ACL.
type ACLDetails struct {
Tests []ACLTest `json:"tests,omitempty"`
ACLs []ACLRow `json:"acls,omitempty"`
Groups map[string][]string `json:"groups,omitempty"`
TagOwners map[string][]string `json:"tagowners,omitempty"`
Hosts map[string]string `json:"hosts,omitempty"`
}
// ACL contains an ACLDetails and metadata.
type ACL struct {
ACL ACLDetails
ETag string // to check with version on server
}
// ACLHuJSON contains the HuJSON string of the ACL and metadata.
type ACLHuJSON struct {
ACL string
Warnings []string
ETag string // to check with version on server
}
// ACL makes a call to the Tailscale server to get a JSON-parsed version of the ACL.
// The JSON-parsed version of the ACL contains no comments as proper JSON does not support
// comments.
func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.ACL: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
// Otherwise, try to decode the response.
var aclDetails ACLDetails
if err = json.Unmarshal(b, &aclDetails); err != nil {
return nil, err
}
acl = &ACL{
ACL: aclDetails,
ETag: resp.Header.Get("ETag"),
}
return acl, nil
}
// ACLHuJSON makes a call to the Tailscale server to get the ACL HuJSON and returns
// it as a string.
// HuJSON is JSON with a few modifications to make it more human-friendly. The primary
// changes are allowing comments and trailing comments. See the following links for more info:
// https://tailscale.com/kb/1018/acls?q=acl#tailscale-acl-policy-format
// https://github.com/tailscale/hujson
func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.ACLHuJSON: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl?details=1", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/hujson")
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
data := struct {
ACL []byte `json:"acl"`
Warnings []string `json:"warnings"`
}{}
if err := json.Unmarshal(b, &data); err != nil {
return nil, err
}
acl = &ACLHuJSON{
ACL: string(data.ACL),
Warnings: data.Warnings,
ETag: resp.Header.Get("ETag"),
}
return acl, nil
}
// ACLTestFailureSummary specifies a user for which ACL tests
// failed and the related user-friendly error messages.
//
// ACLTestFailureSummary specifies the JSON format sent to the
// JavaScript client to be rendered in the HTML.
type ACLTestFailureSummary struct {
User string `json:"user"`
Errors []string `json:"errors"`
}
// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary.
type ACLTestError struct {
ErrResponse
Data []ACLTestFailureSummary `json:"data"`
}
func (e ACLTestError) Error() string {
return fmt.Sprintf("%s, Data: %+v", e.ErrResponse.Error(), e.Data)
}
func (c *Client) aclPOSTRequest(ctx context.Context, body []byte, avoidCollisions bool, etag, acceptHeader string) ([]byte, string, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
if err != nil {
return nil, "", err
}
if avoidCollisions {
req.Header.Set("If-Match", etag)
}
req.Header.Set("Accept", acceptHeader)
req.Header.Set("Content-Type", "application/hujson")
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, "", err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
// check if test error
var ate ACLTestError
if err := json.Unmarshal(b, &ate); err != nil {
return nil, "", err
}
ate.Status = resp.StatusCode
return nil, "", ate
}
return b, resp.Header.Get("ETag"), nil
}
// SetACL sends a POST request to update the ACL according to the provided ACL object. If
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
// header to check if the previously obtained ACL was the latest version and that no updates
// were missed.
//
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
// Returns error if ACL has tests that fail.
// Returns error if there are other errors with the ACL.
func (c *Client) SetACL(ctx context.Context, acl ACL, avoidCollisions bool) (res *ACL, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetACL: %w", err)
}
}()
postData, err := json.Marshal(acl.ACL)
if err != nil {
return nil, err
}
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/json")
if err != nil {
return nil, err
}
// Otherwise, try to decode the response.
var aclDetails ACLDetails
if err = json.Unmarshal(b, &aclDetails); err != nil {
return nil, err
}
res = &ACL{
ACL: aclDetails,
ETag: etag,
}
return res, nil
}
// SetACLHuJSON sends a POST request to update the ACL according to the provided ACL object. If
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
// header to check if the previously obtained ACL was the latest version and that no updates
// were missed.
//
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
// Returns error if the HuJSON is invalid.
// Returns error if ACL has tests that fail.
// Returns error if there are other errors with the ACL.
func (c *Client) SetACLHuJSON(ctx context.Context, acl ACLHuJSON, avoidCollisions bool) (res *ACLHuJSON, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetACLHuJSON: %w", err)
}
}()
postData := []byte(acl.ACL)
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/hujson")
if err != nil {
return nil, err
}
res = &ACLHuJSON{
ACL: string(b),
ETag: etag,
}
return res, nil
}
// UserRuleMatch specifies the source users/groups/hosts that a rule targets
// and the destination ports that they can access.
// LineNumber is only useful for requests provided in HuJSON form.
// While JSON requests will have LineNumber, the value is not useful.
type UserRuleMatch struct {
Users []string `json:"users"`
Ports []string `json:"ports"`
LineNumber int `json:"lineNumber"`
}
// ACLPreviewResponse is the response type of previewACLPostRequest
type ACLPreviewResponse struct {
Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport.
Type string `json:"type"` // The request type: currently only "user" or "ipport".
PreviewFor string `json:"previewFor"` // A specific user or ipport.
}
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
type ACLPreview struct {
Matches []UserRuleMatch `json:"matches"`
User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser
IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort
}
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/preview", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Add("type", previewType)
q.Add("previewFor", previewFor)
req.URL.RawQuery = q.Encode()
req.Header.Set("Content-Type", "application/hujson")
c.setAuth(req)
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
if err = json.Unmarshal(b, &res); err != nil {
return nil, err
}
return res, nil
}
// PreviewACLForUser determines what rules match a given ACL for a user.
// The ACL can be a locally modified or clean ACL obtained from server.
//
// Returns ACLPreview on success with matches in a slice. If there are no matches,
// the call is still successful but Matches will be an empty slice.
// Returns error if the provided ACL is invalid.
func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (res *ACLPreview, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.PreviewACLForUser: %w", err)
}
}()
postData, err := json.Marshal(acl.ACL)
if err != nil {
return nil, err
}
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
if err != nil {
return nil, err
}
return &ACLPreview{
Matches: b.Matches,
User: b.PreviewFor,
}, nil
}
// PreviewACLForIPPort determines what rules match a given ACL for a ipport.
// The ACL can be a locally modified or clean ACL obtained from server.
//
// Returns ACLPreview on success with matches in a slice. If there are no matches,
// the call is still successful but Matches will be an empty slice.
// Returns error if the provided ACL is invalid.
func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netaddr.IPPort) (res *ACLPreview, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.PreviewACLForIPPort: %w", err)
}
}()
postData, err := json.Marshal(acl.ACL)
if err != nil {
return nil, err
}
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport.String())
if err != nil {
return nil, err
}
return &ACLPreview{
Matches: b.Matches,
IPPort: b.PreviewFor,
}, nil
}
// PreviewACLHuJSONForUser determines what rules match a given ACL for a user.
// The ACL can be a locally modified or clean ACL obtained from server.
//
// Returns ACLPreview on success with matches in a slice. If there are no matches,
// the call is still successful but Matches will be an empty slice.
// Returns error if the provided ACL is invalid.
func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, user string) (res *ACLPreview, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.PreviewACLHuJSONForUser: %w", err)
}
}()
postData := []byte(acl.ACL)
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
if err != nil {
return nil, err
}
return &ACLPreview{
Matches: b.Matches,
User: b.PreviewFor,
}, nil
}
// PreviewACLHuJSONForIPPort determines what rules match a given ACL for a ipport.
// The ACL can be a locally modified or clean ACL obtained from server.
//
// Returns ACLPreview on success with matches in a slice. If there are no matches,
// the call is still successful but Matches will be an empty slice.
// Returns error if the provided ACL is invalid.
func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, ipport string) (res *ACLPreview, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.PreviewACLHuJSONForIPPort: %w", err)
}
}()
postData := []byte(acl.ACL)
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport)
if err != nil {
return nil, err
}
return &ACLPreview{
Matches: b.Matches,
IPPort: b.PreviewFor,
}, nil
}
// ValidateACLJSON takes in the given source and destination (in this situation,
// it is assumed that you are checking whether the source can connect to destination)
// and creates an ACLTest from that. It then sends the ACLTest to the control api acl
// validate endpoint, where the test is run. It returns a nil ACLTestError pointer if
// no test errors occur.
func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (testErr *ACLTestError, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.ValidateACLJSON: %w", err)
}
}()
tests := []ACLTest{ACLTest{User: source, Allow: []string{dest}}}
postData, err := json.Marshal(tests)
if err != nil {
return nil, err
}
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/validate", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(postData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("control api responsed with %d status code", resp.StatusCode)
}
// The test ran without fail
if len(b) == 0 {
return nil, nil
}
var res ACLTestError
// The test returned errors.
if err = json.Unmarshal(b, &res); err != nil {
// failed to unmarshal
return nil, err
}
return &res, nil
}

View File

@@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package apitype contains types for the Tailscale local API.
// Package apitype contains types for the Tailscale local API and control plane API.
package apitype
import "tailscale.com/tailcfg"

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package apitype
type DNSConfig struct {
Resolvers []DNSResolver `json:"resolvers"`
FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
Routes map[string][]DNSResolver `json:"routes"`
Domains []string `json:"domains"`
Nameservers []string `json:"nameservers"`
Proxied bool `json:"proxied"`
PerDomain bool `json:",omitempty"`
}
type DNSResolver struct {
Addr string `json:"addr"`
BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
}

262
client/tailscale/devices.go Normal file
View File

@@ -0,0 +1,262 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.18
// +build go1.18
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"tailscale.com/types/opt"
)
type GetDevicesResponse struct {
Devices []*Device `json:"devices"`
}
type DerpRegion struct {
Preferred bool `json:"preferred,omitempty"`
LatencyMilliseconds float64 `json:"latencyMs"`
}
type ClientConnectivity struct {
Endpoints []string `json:"endpoints"`
DERP string `json:"derp"`
MappingVariesByDestIP opt.Bool `json:"mappingVariesByDestIP"`
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
DERPLatency map[string]DerpRegion `json:"latency"`
ClientSupports map[string]opt.Bool `json:"clientSupports"`
}
type Device struct {
// Addresses is a list of the devices's Tailscale IP addresses.
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
Addresses []string `json:"addresses"`
DeviceID string `json:"id"`
User string `json:"user"`
Name string `json:"name"`
Hostname string `json:"hostname"`
ClientVersion string `json:"clientVersion"` // Empty for external devices.
UpdateAvailable bool `json:"updateAvailable"` // Empty for external devices.
OS string `json:"os"`
Created string `json:"created"` // Empty for external devices.
LastSeen string `json:"lastSeen"`
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
Expires string `json:"expires"`
Authorized bool `json:"authorized"`
IsExternal bool `json:"isExternal"`
MachineKey string `json:"machineKey"` // Empty for external devices.
NodeKey string `json:"nodeKey"`
// BlocksIncomingConnections is configured via the device's
// Tailscale client preferences. This field is only reported
// to the API starting with Tailscale 1.3.x clients.
BlocksIncomingConnections bool `json:"blocksIncomingConnections"`
// The following fields are not included by default:
// EnabledRoutes are the previously-approved subnet routes
// (e.g. "192.168.4.16/24", "10.5.2.4/32").
EnabledRoutes []string `json:"enabledRoutes"` // Empty for external devices.
// AdvertisedRoutes are the subnets (both enabled and not enabled)
// being requested from the node.
AdvertisedRoutes []string `json:"advertisedRoutes"` // Empty for external devices.
ClientConnectivity *ClientConnectivity `json:"clientConnectivity"`
}
// DeviceFieldsOpts determines which fields should be returned in the response.
//
// Please only use DeviceAllFields and DeviceDefaultFields.
// Other DeviceFieldsOpts are not supported.
//
// TODO: Support other DeviceFieldsOpts.
// In the future, users should be able to create their own DeviceFieldsOpts
// as valid arguments by setting the fields they want returned to a "non-nil"
// value. For example, DeviceFieldsOpts{NodeID: "true"} should only return NodeIDs.
type DeviceFieldsOpts Device
func (d *DeviceFieldsOpts) addFieldsToQueryParameter() string {
if d == DeviceDefaultFields || d == nil {
return "default"
}
if d == DeviceAllFields {
return "all"
}
return ""
}
var (
DeviceAllFields = &DeviceFieldsOpts{}
// DeviceDefaultFields specifies that the following fields are returned:
// Addresses, NodeID, User, Name, Hostname, ClientVersion, UpdateAvailable,
// OS, Created, LastSeen, KeyExpiryDisabled, Expires, Authorized, IsExternal
// MachineKey, NodeKey, BlocksIncomingConnections.
DeviceDefaultFields = &DeviceFieldsOpts{}
)
// Devices retrieves the list of devices for a tailnet.
//
// See the Device structure for the list of fields hidden for external devices.
// The optional fields parameter specifies which fields of the devices to return; currently
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
// Other values are currently undefined.
func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceList []*Device, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.Devices: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/tailnet/%s/devices", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
// Add fields.
fieldStr := fields.addFieldsToQueryParameter()
q := req.URL.Query()
q.Add("fields", fieldStr)
req.URL.RawQuery = q.Encode()
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var devices GetDevicesResponse
err = json.Unmarshal(b, &devices)
return devices.Devices, err
}
// Device retrieved the details for a specific device.
//
// See the Device structure for the list of fields hidden for an external device.
// The optional fields parameter specifies which fields of the devices to return; currently
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
// Other values are currently undefined.
func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFieldsOpts) (device *Device, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.Device: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/device/%s", c.baseURL(), deviceID)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
// Add fields.
fieldStr := fields.addFieldsToQueryParameter()
q := req.URL.Query()
q.Add("fields", fieldStr)
req.URL.RawQuery = q.Encode()
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
err = json.Unmarshal(b, &device)
return device, err
}
// DeleteDevice deletes the specified device from the Client's tailnet.
// NOTE: Only devices that belong to the Client's tailnet can be deleted.
// Deleting external devices is not supported.
func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DeleteDevice: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/device/%s", c.baseURL(), url.PathEscape(deviceID))
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
if err != nil {
return err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return handleErrorResponse(b, resp)
}
return nil
}
// AuthorizeDevice marks a device as authorized.
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error {
path := fmt.Sprintf("%s/api/v2/device/%s/authorized", c.baseURL(), url.PathEscape(deviceID))
req, err := http.NewRequestWithContext(ctx, "POST", path, strings.NewReader(`{"authorized":true}`))
if err != nil {
return err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return handleErrorResponse(b, resp)
}
return nil
}
// SetTags updates the ACL tags on a device.
func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) error {
params := &struct {
Tags []string `json:"tags"`
}{Tags: tags}
data, err := json.Marshal(params)
if err != nil {
return err
}
path := fmt.Sprintf("%s/api/v2/device/%s/tags", c.baseURL(), url.PathEscape(deviceID))
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
if err != nil {
return err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return handleErrorResponse(b, resp)
}
return nil
}

235
client/tailscale/dns.go Normal file
View File

@@ -0,0 +1,235 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.18
// +build go1.18
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"tailscale.com/client/tailscale/apitype"
)
// DNSNameServers is returned when retrieving the list of nameservers.
// It is also the structure provided when setting nameservers.
type DNSNameServers struct {
DNS []string `json:"dns"` // DNS name servers
}
// DNSNameServersPostResponse is returned when setting the list of DNS nameservers.
//
// It includes the MagicDNS status since nameservers changes may affect MagicDNS.
type DNSNameServersPostResponse struct {
DNS []string `json:"dns"` // DNS name servers
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
}
// DNSSearchpaths is the list of search paths for a given domain.
type DNSSearchPaths struct {
SearchPaths []string `json:"searchPaths"` // DNS search paths
}
// DNSPreferences is the preferences set for a given tailnet.
//
// It includes MagicDNS which can be turned on or off. To enable MagicDNS,
// there must be at least one nameserver. When all nameservers are removed,
// MagicDNS is disabled.
type DNSPreferences struct {
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
}
func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
return b, nil
}
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData interface{}) ([]byte, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
data, err := json.Marshal(&postData)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
req.Header.Set("Content-Type", "application/json")
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
return b, nil
}
// DNSConfig retrieves the DNSConfig settings for a domain.
func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DNSConfig: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "config")
if err != nil {
return nil, err
}
var dnsResp apitype.DNSConfig
err = json.Unmarshal(b, &dnsResp)
return &dnsResp, err
}
func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetDNSConfig: %w", err)
}
}()
var dnsResp apitype.DNSConfig
b, err := c.dnsPOSTRequest(ctx, "config", cfg)
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &dnsResp)
return &dnsResp, err
}
// NameServers retrieves the list of nameservers set for a domain.
func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.NameServers: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "nameservers")
if err != nil {
return nil, err
}
var dnsResp DNSNameServers
err = json.Unmarshal(b, &dnsResp)
return dnsResp.DNS, err
}
// SetNameServers sets the list of nameservers for a tailnet to the list provided
// by the user.
//
// It returns the new list of nameservers and the MagicDNS status in case it was
// affected by the change. For example, removing all nameservers will turn off
// MagicDNS.
func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetNameServers: %w", err)
}
}()
dnsReq := DNSNameServers{DNS: nameservers}
b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq)
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &dnsResp)
return dnsResp, err
}
// DNSPreferences retrieves the DNS preferences set for a tailnet.
//
// It returns the status of MagicDNS.
func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DNSPreferences: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "preferences")
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &dnsResp)
return dnsResp, err
}
// SetDNSPreferences sets the DNS preferences for a tailnet.
//
// MagicDNS can only be enabled when there is at least one nameserver provided.
// When all nameservers are removed, MagicDNS is disabled and will stay disabled,
// unless explicitly enabled by a user again.
func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err)
}
}()
dnsReq := DNSPreferences{MagicDNS: magicDNS}
b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq)
if err != nil {
return
}
err = json.Unmarshal(b, &dnsResp)
return dnsResp, err
}
// SearchPaths retrieves the list of searchpaths set for a tailnet.
func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SearchPaths: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "searchpaths")
if err != nil {
return nil, err
}
var dnsResp *DNSSearchPaths
err = json.Unmarshal(b, &dnsResp)
return dnsResp.SearchPaths, err
}
// SetSearchPaths sets the list of searchpaths for a tailnet.
func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetSearchPaths: %w", err)
}
}()
dnsReq := DNSSearchPaths{SearchPaths: searchpaths}
b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq)
if err != nil {
return nil, err
}
var dnsResp DNSSearchPaths
err = json.Unmarshal(b, &dnsResp)
return dnsResp.SearchPaths, err
}

View File

@@ -0,0 +1,711 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.18
// +build go1.18
package tailscale
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/http/httptrace"
"net/url"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"time"
"go4.org/mem"
"inet.af/netaddr"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netutil"
"tailscale.com/paths"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
)
// defaultLocalClient is the default LocalClient when using the legacy
// package-level functions.
var defaultLocalClient LocalClient
// LocalClient is a client to Tailscale's "local API", communicating with the
// Tailscale daemon on the local machine. Its API is not necessarily stable and
// subject to changes between releases. Some API calls have stricter
// compatibility guarantees, once they've been widely adopted. See method docs
// for details.
//
// Its zero value is valid to use.
//
// Any exported fields should be set before using methods on the type
// and not changed thereafter.
type LocalClient struct {
// Dial optionally specifies an alternate func that connects to the local
// machine's tailscaled or equivalent. If nil, a default is used.
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
// Socket specifies an alternate path to the local Tailscale socket.
// If empty, a platform-specific default is used.
Socket string
// UseSocketOnly, if true, tries to only connect to tailscaled via the
// Unix socket and not via fallback mechanisms as done on macOS when
// connecting to the GUI client variants.
UseSocketOnly bool
// tsClient does HTTP requests to the local Tailscale daemon.
// It's lazily initialized on first use.
tsClient *http.Client
tsClientOnce sync.Once
}
func (lc *LocalClient) socket() string {
if lc.Socket != "" {
return lc.Socket
}
return paths.DefaultTailscaledSocket()
}
func (lc *LocalClient) dialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
if lc.Dial != nil {
return lc.Dial
}
return lc.defaultDialer
}
func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) {
if addr != "local-tailscaled.sock:80" {
return nil, fmt.Errorf("unexpected URL address %q", addr)
}
if !lc.UseSocketOnly {
// On macOS, when dialing from non-sandboxed program to sandboxed GUI running
// a TCP server on a random port, find the random port. For HTTP connections,
// we don't send the token. It gets added in an HTTP Basic-Auth header.
if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil {
var d net.Dialer
return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port))
}
}
s := safesocket.DefaultConnectionStrategy(lc.socket())
// The user provided a non-default tailscaled socket address.
// Connect only to exactly what they provided.
s.UseFallback(false)
return safesocket.Connect(s)
}
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
//
// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4.
//
// The hostname must be "local-tailscaled.sock", even though it
// doesn't actually do any DNS lookup. The actual means of connecting to and
// authenticating to the local Tailscale daemon vary by platform.
//
// DoLocalRequest may mutate the request to add Authorization headers.
func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error) {
lc.tsClientOnce.Do(func() {
lc.tsClient = &http.Client{
Transport: &http.Transport{
DialContext: lc.dialer(),
},
}
})
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
req.SetBasicAuth("", token)
}
return lc.tsClient.Do(req)
}
func (lc *LocalClient) doLocalRequestNiceError(req *http.Request) (*http.Response, error) {
res, err := lc.DoLocalRequest(req)
if err == nil {
if server := res.Header.Get("Tailscale-Version"); server != "" && server != ipn.IPCVersion() && onVersionMismatch != nil {
onVersionMismatch(ipn.IPCVersion(), server)
}
if res.StatusCode == 403 {
all, _ := ioutil.ReadAll(res.Body)
return nil, &AccessDeniedError{errors.New(errorMessageFromBody(all))}
}
return res, nil
}
if ue, ok := err.(*url.Error); ok {
if oe, ok := ue.Err.(*net.OpError); ok && oe.Op == "dial" {
path := req.URL.Path
pathPrefix, _, _ := strings.Cut(path, "?")
return nil, fmt.Errorf("Failed to connect to local Tailscale daemon for %s; %s Error: %w", pathPrefix, tailscaledConnectHint(), oe)
}
}
return nil, err
}
type errorJSON struct {
Error string
}
// AccessDeniedError is an error due to permissions.
type AccessDeniedError struct {
err error
}
func (e *AccessDeniedError) Error() string { return fmt.Sprintf("Access denied: %v", e.err) }
func (e *AccessDeniedError) Unwrap() error { return e.err }
// IsAccessDeniedError reports whether err is or wraps an AccessDeniedError.
func IsAccessDeniedError(err error) bool {
var ae *AccessDeniedError
return errors.As(err, &ae)
}
// bestError returns either err, or if body contains a valid JSON
// object of type errorJSON, its non-empty error body.
func bestError(err error, body []byte) error {
var j errorJSON
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
return errors.New(j.Error)
}
return err
}
func errorMessageFromBody(body []byte) string {
var j errorJSON
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
return j.Error
}
return strings.TrimSpace(string(body))
}
var onVersionMismatch func(clientVer, serverVer string)
// SetVersionMismatchHandler sets f as the version mismatch handler
// to be called when the client (the current process) has a version
// number that doesn't match the server's declared version.
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
onVersionMismatch = f
}
func (lc *LocalClient) send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body)
if err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
slurp, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode != wantStatus {
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
return nil, bestError(err, slurp)
}
return slurp, nil
}
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
return lc.send(ctx, "GET", path, 200, nil)
}
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
//
// Deprecated: use LocalClient.WhoIs.
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
return defaultLocalClient.WhoIs(ctx, remoteAddr)
}
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
if err != nil {
return nil, err
}
r := new(apitype.WhoIsResponse)
if err := json.Unmarshal(body, r); err != nil {
if max := 200; len(body) > max {
body = append(body[:max], "..."...)
}
return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", body)
}
return r, nil
}
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
func (lc *LocalClient) Goroutines(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/goroutines")
}
// DaemonMetrics returns the Tailscale daemon's metrics in
// the Prometheus text exposition format.
func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/metrics")
}
// Profile returns a pprof profile of the Tailscale daemon.
func (lc *LocalClient) Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) {
var secArg string
if sec < 0 || sec > 300 {
return nil, errors.New("duration out of range")
}
if sec != 0 || pprofType == "profile" {
secArg = fmt.Sprint(sec)
}
return lc.get200(ctx, fmt.Sprintf("/localapi/v0/profile?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg))
}
// BugReport logs and returns a log marker that can be shared by the user with support.
func (lc *LocalClient) BugReport(ctx context.Context, note string) (string, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil)
if err != nil {
return "", err
}
return strings.TrimSpace(string(body)), nil
}
// DebugAction invokes a debug action, such as "rebind" or "restun".
// These are development tools and subject to change or removal over time.
func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
return fmt.Errorf("error %w: %s", err, body)
}
return nil
}
// Status returns the Tailscale daemon's status.
func Status(ctx context.Context) (*ipnstate.Status, error) {
return defaultLocalClient.Status(ctx)
}
// Status returns the Tailscale daemon's status.
func (lc *LocalClient) Status(ctx context.Context) (*ipnstate.Status, error) {
return lc.status(ctx, "")
}
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return defaultLocalClient.StatusWithoutPeers(ctx)
}
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
func (lc *LocalClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return lc.status(ctx, "?peers=false")
}
func (lc *LocalClient) status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
body, err := lc.get200(ctx, "/localapi/v0/status"+queryString)
if err != nil {
return nil, err
}
st := new(ipnstate.Status)
if err := json.Unmarshal(body, st); err != nil {
return nil, err
}
return st, nil
}
// IDToken is a request to get an OIDC ID token for an audience.
// The token can be presented to any resource provider which offers OIDC
// Federation.
func (lc *LocalClient) IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/id-token?aud="+url.QueryEscape(aud))
if err != nil {
return nil, err
}
tr := new(tailcfg.TokenResponse)
if err := json.Unmarshal(body, tr); err != nil {
return nil, err
}
return tr, nil
}
func (lc *LocalClient) WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
body, err := lc.get200(ctx, "/localapi/v0/files/")
if err != nil {
return nil, err
}
var wfs []apitype.WaitingFile
if err := json.Unmarshal(body, &wfs); err != nil {
return nil, err
}
return wfs, nil
}
func (lc *LocalClient) DeleteWaitingFile(ctx context.Context, baseName string) error {
_, err := lc.send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
return err
}
func (lc *LocalClient) GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil)
if err != nil {
return nil, 0, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, 0, err
}
if res.ContentLength == -1 {
res.Body.Close()
return nil, 0, fmt.Errorf("unexpected chunking")
}
if res.StatusCode != 200 {
body, _ := ioutil.ReadAll(res.Body)
res.Body.Close()
return nil, 0, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
return res.Body, res.ContentLength, nil
}
func (lc *LocalClient) FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
body, err := lc.get200(ctx, "/localapi/v0/file-targets")
if err != nil {
return nil, err
}
var fts []apitype.FileTarget
if err := json.Unmarshal(body, &fts); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return fts, nil
}
// PushFile sends Taildrop file r to target.
//
// A size of -1 means unknown.
// The name parameter is the original filename, not escaped.
func (lc *LocalClient) PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
req, err := http.NewRequestWithContext(ctx, "PUT", "http://local-tailscaled.sock/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
if err != nil {
return err
}
if size != -1 {
req.ContentLength = size
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return err
}
if res.StatusCode == 200 {
io.Copy(io.Discard, res.Body)
return nil
}
all, _ := io.ReadAll(res.Body)
return bestError(fmt.Errorf("%s: %s", res.Status, all), all)
}
// CheckIPForwarding asks the local Tailscale daemon whether it looks like the
// machine is properly configured to forward IP packets as a subnet router
// or exit node.
func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/check-ip-forwarding")
if err != nil {
return err
}
var jres struct {
Warning string
}
if err := json.Unmarshal(body, &jres); err != nil {
return fmt.Errorf("invalid JSON from check-ip-forwarding: %w", err)
}
if jres.Warning != "" {
return errors.New(jres.Warning)
}
return nil
}
// CheckPrefs validates the provided preferences, without making any changes.
//
// The CLI uses this before a Start call to fail fast if the preferences won't
// work. Currently (2022-04-18) this only checks for SSH server compatibility.
// Note that EditPrefs does the same validation as this, so call CheckPrefs before
// EditPrefs is not necessary.
func (lc *LocalClient) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
pj, err := json.Marshal(p)
if err != nil {
return err
}
_, err = lc.send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, bytes.NewReader(pj))
return err
}
func (lc *LocalClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
body, err := lc.get200(ctx, "/localapi/v0/prefs")
if err != nil {
return nil, err
}
var p ipn.Prefs
if err := json.Unmarshal(body, &p); err != nil {
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
}
return &p, nil
}
func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
mpj, err := json.Marshal(mp)
if err != nil {
return nil, err
}
body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj))
if err != nil {
return nil, err
}
var p ipn.Prefs
if err := json.Unmarshal(body, &p); err != nil {
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
}
return &p, nil
}
func (lc *LocalClient) Logout(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil)
return err
}
// SetDNS adds a DNS TXT record for the given domain name, containing
// the provided TXT value. The intended use case is answering
// LetsEncrypt/ACME dns-01 challenges.
//
// The control plane will only permit SetDNS requests with very
// specific names and values. The name should be
// "_acme-challenge." + your node's MagicDNS name. It's expected that
// clients cache the certs from LetsEncrypt (or whichever CA is
// providing them) and only request new ones as needed; the control plane
// rate limits SetDNS requests.
//
// This is a low-level interface; it's expected that most Tailscale
// users use a higher level interface to getting/using TLS
// certificates.
func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
v := url.Values{}
v.Set("name", name)
v.Set("value", value)
_, err := lc.send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
return err
}
// DialTCP connects to the host's port via Tailscale.
//
// 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) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
connCh <- info.Conn
},
}
ctx = httptrace.WithClientTrace(ctx, &trace)
req, err := http.NewRequestWithContext(ctx, "POST", "http://local-tailscaled.sock/localapi/v0/dial", nil)
if err != nil {
return nil, err
}
req.Header = http.Header{
"Upgrade": []string{"ts-dial"},
"Connection": []string{"upgrade"},
"Dial-Host": []string{host},
"Dial-Port": []string{fmt.Sprint(port)},
}
res, err := lc.DoLocalRequest(req)
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusSwitchingProtocols {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
}
// From here on, the underlying net.Conn is ours to use, but there
// is still a read buffer attached to it within resp.Body. So, we
// must direct I/O through resp.Body, but we can still use the
// underlying net.Conn for stuff like deadlines.
var switchedConn net.Conn
select {
case switchedConn = <-connCh:
default:
}
if switchedConn == nil {
res.Body.Close()
return nil, fmt.Errorf("httptrace didn't provide a connection")
}
rwc, ok := res.Body.(io.ReadWriteCloser)
if !ok {
res.Body.Close()
return nil, errors.New("http Transport did not provide a writable body")
}
return netutil.NewAltReadWriteCloserConn(rwc, switchedConn), nil
}
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
// It is intended to be used with netcheck to see availability of DERPs.
func (lc *LocalClient) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
var derpMap tailcfg.DERPMap
res, err := lc.send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
if err != nil {
return nil, err
}
if err = json.Unmarshal(res, &derpMap); err != nil {
return nil, fmt.Errorf("invalid derp map json: %w", err)
}
return &derpMap, nil
}
// CertPair returns a cert and private key for the provided DNS domain.
//
// It returns a cached certificate from disk if it's still valid.
//
// Deprecated: use LocalClient.CertPair.
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return defaultLocalClient.CertPair(ctx, domain)
}
// CertPair returns a cert and private key for the provided DNS domain.
//
// It returns a cached certificate from disk if it's still valid.
//
// API maturity: this is considered a stable API.
func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
res, err := lc.send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
if err != nil {
return nil, nil, err
}
// with ?type=pair, the response PEM is first the one private
// key PEM block, then the cert PEM blocks.
i := mem.Index(mem.B(res), mem.S("--\n--"))
if i == -1 {
return nil, nil, fmt.Errorf("unexpected output: no delimiter")
}
i += len("--\n")
keyPEM, certPEM = res[:i], res[i:]
if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
return nil, nil, fmt.Errorf("unexpected output: key in cert")
}
return certPEM, keyPEM, nil
}
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
//
// It returns a cached certificate from disk if it's still valid.
//
// It's the right signature to use as the value of
// tls.Config.GetCertificate.
//
// Deprecated: use LocalClient.GetCertificate.
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return defaultLocalClient.GetCertificate(hi)
}
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
//
// It returns a cached certificate from disk if it's still valid.
//
// It's the right signature to use as the value of
// tls.Config.GetCertificate.
//
// API maturity: this is considered a stable API.
func (lc *LocalClient) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if hi == nil || hi.ServerName == "" {
return nil, errors.New("no SNI ServerName")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
name := hi.ServerName
if !strings.Contains(name, ".") {
if v, ok := lc.ExpandSNIName(ctx, name); ok {
name = v
}
}
certPEM, keyPEM, err := lc.CertPair(ctx, name)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
return &cert, nil
}
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
//
// Deprecated: use LocalClient.ExpandSNIName.
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
return defaultLocalClient.ExpandSNIName(ctx, name)
}
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
st, err := lc.StatusWithoutPeers(ctx)
if err != nil {
return "", false
}
for _, d := range st.CertDomains {
if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
return d, true
}
}
return "", false
}
// Ping sends a ping of the provided type to the provided IP and waits
// for its response.
func (lc *LocalClient) Ping(ctx context.Context, ip netaddr.IP, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
v := url.Values{}
v.Set("ip", ip.String())
v.Set("type", string(pingtype))
body, err := lc.send(ctx, "POST", "/localapi/v0/ping?"+v.Encode(), 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
pr := new(ipnstate.PingResult)
if err := json.Unmarshal(body, pr); err != nil {
return nil, err
}
return pr, nil
}
// tailscaledConnectHint gives a little thing about why tailscaled (or
// platform equivalent) is not answering localapi connections.
//
// It ends in a punctuation. See caller.
func tailscaledConnectHint() string {
if runtime.GOOS != "linux" {
// TODO(bradfitz): flesh this out
return "not running?"
}
out, err := exec.Command("systemctl", "show", "tailscaled.service", "--no-page", "--property", "LoadState,ActiveState,SubState").Output()
if err != nil {
return "not running?"
}
// Parse:
// LoadState=loaded
// ActiveState=inactive
// SubState=dead
st := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
if k, v, ok := strings.Cut(line, "="); ok {
st[k] = strings.TrimSpace(v)
}
}
if st["LoadState"] == "loaded" &&
(st["SubState"] != "running" || st["ActiveState"] != "active") {
return "systemd tailscaled.service not running."
}
return "not running?"
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.18
// +build go1.18
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"inet.af/netaddr"
)
// Routes contains the lists of subnet routes that are currently advertised by a device,
// as well as the subnets that are enabled to be routed by the device.
type Routes struct {
AdvertisedRoutes []netaddr.IPPrefix `json:"advertisedRoutes"`
EnabledRoutes []netaddr.IPPrefix `json:"enabledRoutes"`
}
// Routes retrieves the list of subnet routes that have been enabled for a device.
// The routes that are returned are not necessarily advertised by the device,
// they have only been preapproved.
func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.Routes: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var sr Routes
err = json.Unmarshal(b, &sr)
return &sr, err
}
type postRoutesParams struct {
Routes []netaddr.IPPrefix `json:"routes"`
}
// SetRoutes updates the list of subnets that are enabled for a device.
// Subnets must be parsable by inet.af/netaddr.ParseIPPrefix.
// Subnets do not have to be currently advertised by a device, they may be pre-enabled.
// Returns the updated list of enabled and advertised subnet routes in a *Routes object.
func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netaddr.IPPrefix) (routes *Routes, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetRoutes: %w", err)
}
}()
params := &postRoutesParams{Routes: subnets}
data, err := json.Marshal(params)
if err != nil {
return nil, err
}
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var srr *Routes
if err := json.Unmarshal(b, &srr); err != nil {
return nil, err
}
return srr, err
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.18
// +build go1.18
package tailscale
import (
"context"
"fmt"
"net/http"
"net/url"
)
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DeleteTailnet: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
if err != nil {
return err
}
c.setAuth(req)
b, resp, err := c.sendRequest(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return handleErrorResponse(b, resp)
}
return nil
}

View File

@@ -1,616 +1,160 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.18
// +build go1.18
// Package tailscale contains Tailscale client code.
// Package tailscale contains Go clients for the Tailscale Local API and
// Tailscale control plane API.
//
// Warning: this package is in development and makes no API compatibility
// promises as of 2022-04-29. It is subject to change at any time.
package tailscale
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/http/httptrace"
"net/url"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"time"
"go4.org/mem"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netutil"
"tailscale.com/paths"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/version"
)
var (
// TailscaledSocket is the tailscaled Unix socket. It's used by the TailscaledDialer.
TailscaledSocket = paths.DefaultTailscaledSocket()
// TailscaledSocketSetExplicitly reports whether the user explicitly set TailscaledSocket.
TailscaledSocketSetExplicitly bool
// TailscaledDialer is the DialContext func that connects to the local machine's
// tailscaled or equivalent.
TailscaledDialer = defaultDialer
)
func defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) {
if addr != "local-tailscaled.sock:80" {
return nil, fmt.Errorf("unexpected URL address %q", addr)
}
// TODO: make this part of a safesocket.ConnectionStrategy
if !TailscaledSocketSetExplicitly {
// On macOS, when dialing from non-sandboxed program to sandboxed GUI running
// a TCP server on a random port, find the random port. For HTTP connections,
// we don't send the token. It gets added in an HTTP Basic-Auth header.
if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil {
var d net.Dialer
return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port))
}
}
s := safesocket.DefaultConnectionStrategy(TailscaledSocket)
// The user provided a non-default tailscaled socket address.
// Connect only to exactly what they provided.
s.UseFallback(false)
return safesocket.Connect(s)
}
var (
// tsClient does HTTP requests to the local Tailscale daemon.
// We lazily initialize the client in case the caller wants to
// override TailscaledDialer.
tsClient *http.Client
tsClientOnce sync.Once
)
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
// for now. It was added 2022-04-29 when it was moved to this git repo
// and will be removed when the public API has settled.
//
// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4.
// TODO(bradfitz): remove this after the we're happy with the public API.
var I_Acknowledge_This_API_Is_Unstable = false
// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests.
const defaultAPIBase = "https://api.tailscale.com"
// maxSize is the maximum read size (10MB) of responses from the server.
const maxReadSize = 10 << 20
// Client makes API calls to the Tailscale control plane API server.
//
// The hostname must be "local-tailscaled.sock", even though it
// doesn't actually do any DNS lookup. The actual means of connecting to and
// authenticating to the local Tailscale daemon vary by platform.
// Use NewClient to instantiate one. Exported fields should be set before
// the client is used and not changed thereafter.
type Client struct {
// tailnet is the globally unique identifier for a Tailscale network, such
// as "example.com" or "user@gmail.com".
tailnet string
// auth is the authentication method to use for this client.
// nil means none, which generally won't work, but won't crash.
auth AuthMethod
// BaseURL optionally specifies an alternate API server to use.
// If empty, "https://api.tailscale.com" is used.
BaseURL string
// HTTPClient optionally specifies an alternate HTTP client to use.
// If nil, http.DefaultClient is used.
HTTPClient *http.Client
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
func (c *Client) baseURL() string {
if c.BaseURL != "" {
return c.BaseURL
}
return defaultAPIBase
}
// AuthMethod is the interface for API authentication methods.
//
// DoLocalRequest may mutate the request to add Authorization headers.
func DoLocalRequest(req *http.Request) (*http.Response, error) {
tsClientOnce.Do(func() {
tsClient = &http.Client{
Transport: &http.Transport{
DialContext: TailscaledDialer,
},
}
})
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
req.SetBasicAuth("", token)
}
return tsClient.Do(req)
// Most users will use AuthKey.
type AuthMethod interface {
modifyRequest(req *http.Request)
}
func doLocalRequestNiceError(req *http.Request) (*http.Response, error) {
res, err := DoLocalRequest(req)
if err == nil {
if server := res.Header.Get("Tailscale-Version"); server != "" && server != version.Long && onVersionMismatch != nil {
onVersionMismatch(version.Long, server)
}
if res.StatusCode == 403 {
all, _ := ioutil.ReadAll(res.Body)
return nil, &AccessDeniedError{errors.New(errorMessageFromBody(all))}
}
return res, nil
}
if ue, ok := err.(*url.Error); ok {
if oe, ok := ue.Err.(*net.OpError); ok && oe.Op == "dial" {
path := req.URL.Path
pathPrefix, _, _ := strings.Cut(path, "?")
return nil, fmt.Errorf("Failed to connect to local Tailscale daemon for %s; %s Error: %w", pathPrefix, tailscaledConnectHint(), oe)
}
}
return nil, err
// APIKey is an AuthMethod for NewClient that authenticates requests
// using an authkey.
type APIKey string
func (ak APIKey) modifyRequest(req *http.Request) {
req.SetBasicAuth(string(ak), "")
}
type errorJSON struct {
Error string
func (c *Client) setAuth(r *http.Request) {
if c.auth != nil {
c.auth.modifyRequest(r)
}
}
// AccessDeniedError is an error due to permissions.
type AccessDeniedError struct {
err error
}
func (e *AccessDeniedError) Error() string { return fmt.Sprintf("Access denied: %v", e.err) }
func (e *AccessDeniedError) Unwrap() error { return e.err }
// IsAccessDeniedError reports whether err is or wraps an AccessDeniedError.
func IsAccessDeniedError(err error) bool {
var ae *AccessDeniedError
return errors.As(err, &ae)
}
// bestError returns either err, or if body contains a valid JSON
// object of type errorJSON, its non-empty error body.
func bestError(err error, body []byte) error {
var j errorJSON
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
return errors.New(j.Error)
}
return err
}
func errorMessageFromBody(body []byte) string {
var j errorJSON
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
return j.Error
}
return strings.TrimSpace(string(body))
}
var onVersionMismatch func(clientVer, serverVer string)
// SetVersionMismatchHandler sets f as the version mismatch handler
// to be called when the client (the current process) has a version
// number that doesn't match the server's declared version.
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
onVersionMismatch = f
}
func send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body)
if err != nil {
return nil, err
}
res, err := doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
slurp, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode != wantStatus {
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
return nil, bestError(err, slurp)
}
return slurp, nil
}
func get200(ctx context.Context, path string) ([]byte, error) {
return send(ctx, "GET", path, 200, nil)
}
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
if err != nil {
return nil, err
}
r := new(apitype.WhoIsResponse)
if err := json.Unmarshal(body, r); err != nil {
if max := 200; len(body) > max {
body = append(body[:max], "..."...)
}
return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", body)
}
return r, nil
}
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
func Goroutines(ctx context.Context) ([]byte, error) {
return get200(ctx, "/localapi/v0/goroutines")
}
// DaemonMetrics returns the Tailscale daemon's metrics in
// the Prometheus text exposition format.
func DaemonMetrics(ctx context.Context) ([]byte, error) {
return get200(ctx, "/localapi/v0/metrics")
}
// Profile returns a pprof profile of the Tailscale daemon.
func Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) {
var secArg string
if sec < 0 || sec > 300 {
return nil, errors.New("duration out of range")
}
if sec != 0 || pprofType == "profile" {
secArg = fmt.Sprint(sec)
}
return get200(ctx, fmt.Sprintf("/localapi/v0/profile?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg))
}
// BugReport logs and returns a log marker that can be shared by the user with support.
func BugReport(ctx context.Context, note string) (string, error) {
body, err := send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil)
if err != nil {
return "", err
}
return strings.TrimSpace(string(body)), nil
}
// DebugAction invokes a debug action, such as "rebind" or "restun".
// These are development tools and subject to change or removal over time.
func DebugAction(ctx context.Context, action string) error {
body, err := send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
return fmt.Errorf("error %w: %s", err, body)
}
return nil
}
// Status returns the Tailscale daemon's status.
func Status(ctx context.Context) (*ipnstate.Status, error) {
return status(ctx, "")
}
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return status(ctx, "?peers=false")
}
func status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
body, err := get200(ctx, "/localapi/v0/status"+queryString)
if err != nil {
return nil, err
}
st := new(ipnstate.Status)
if err := json.Unmarshal(body, st); err != nil {
return nil, err
}
return st, nil
}
// IDToken is a request to get an OIDC ID token for an audience.
// The token can be presented to any resource provider which offers OIDC
// Federation.
func IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) {
body, err := get200(ctx, "/localapi/v0/id-token?aud="+url.QueryEscape(aud))
if err != nil {
return nil, err
}
tr := new(tailcfg.TokenResponse)
if err := json.Unmarshal(body, tr); err != nil {
return nil, err
}
return tr, nil
}
func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
body, err := get200(ctx, "/localapi/v0/files/")
if err != nil {
return nil, err
}
var wfs []apitype.WaitingFile
if err := json.Unmarshal(body, &wfs); err != nil {
return nil, err
}
return wfs, nil
}
func DeleteWaitingFile(ctx context.Context, baseName string) error {
_, err := send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
return err
}
func GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil)
if err != nil {
return nil, 0, err
}
res, err := doLocalRequestNiceError(req)
if err != nil {
return nil, 0, err
}
if res.ContentLength == -1 {
res.Body.Close()
return nil, 0, fmt.Errorf("unexpected chunking")
}
if res.StatusCode != 200 {
body, _ := ioutil.ReadAll(res.Body)
res.Body.Close()
return nil, 0, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
return res.Body, res.ContentLength, nil
}
func FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
body, err := get200(ctx, "/localapi/v0/file-targets")
if err != nil {
return nil, err
}
var fts []apitype.FileTarget
if err := json.Unmarshal(body, &fts); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return fts, nil
}
// PushFile sends Taildrop file r to target.
// NewClient is a convenience method for instantiating a new Client.
//
// A size of -1 means unknown.
// The name parameter is the original filename, not escaped.
func PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
req, err := http.NewRequestWithContext(ctx, "PUT", "http://local-tailscaled.sock/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
// tailnet is the globally unique identifier for a Tailscale network, such
// as "example.com" or "user@gmail.com".
// If httpClient is nil, then http.DefaultClient is used.
// "api.tailscale.com" is set as the BaseURL for the returned client
// and can be changed manually by the user.
func NewClient(tailnet string, auth AuthMethod) *Client {
return &Client{
tailnet: tailnet,
auth: auth,
}
}
func (c *Client) Tailnet() string { return c.tailnet }
// Do sends a raw HTTP request, after adding any authentication headers.
func (c *Client) Do(req *http.Request) (*http.Response, error) {
if !I_Acknowledge_This_API_Is_Unstable {
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
}
c.setAuth(req)
return c.httpClient().Do(req)
}
// sendRequest add the authenication key to the request and sends it. It
// receives the response and reads up to 10MB of it.
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
if !I_Acknowledge_This_API_Is_Unstable {
return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
}
c.setAuth(req)
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, resp, err
}
defer resp.Body.Close()
// Read response. Limit the response to 10MB.
body := io.LimitReader(resp.Body, maxReadSize+1)
b, err := ioutil.ReadAll(body)
if len(b) > maxReadSize {
err = errors.New("API response too large")
}
return b, resp, err
}
// ErrResponse is the HTTP error returned by the Tailscale server.
type ErrResponse struct {
Status int
Message string
}
func (e ErrResponse) Error() string {
return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message)
}
// handleErrorResponse decodes the error message from the server and returns
// an ErrResponse from it.
func handleErrorResponse(b []byte, resp *http.Response) error {
var errResp ErrResponse
if err := json.Unmarshal(b, &errResp); err != nil {
return err
}
if size != -1 {
req.ContentLength = size
}
res, err := doLocalRequestNiceError(req)
if err != nil {
return err
}
if res.StatusCode == 200 {
io.Copy(io.Discard, res.Body)
return nil
}
all, _ := io.ReadAll(res.Body)
return bestError(fmt.Errorf("%s: %s", res.Status, all), all)
}
func CheckIPForwarding(ctx context.Context) error {
body, err := get200(ctx, "/localapi/v0/check-ip-forwarding")
if err != nil {
return err
}
var jres struct {
Warning string
}
if err := json.Unmarshal(body, &jres); err != nil {
return fmt.Errorf("invalid JSON from check-ip-forwarding: %w", err)
}
if jres.Warning != "" {
return errors.New(jres.Warning)
}
return nil
}
// CheckPrefs validates the provided preferences, without making any changes.
//
// The CLI uses this before a Start call to fail fast if the preferences won't
// work. Currently (2022-04-18) this only checks for SSH server compatibility.
// Note that EditPrefs does the same validation as this, so call CheckPrefs before
// EditPrefs is not necessary.
func CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
pj, err := json.Marshal(p)
if err != nil {
return err
}
_, err = send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, bytes.NewReader(pj))
return err
}
func GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
body, err := get200(ctx, "/localapi/v0/prefs")
if err != nil {
return nil, err
}
var p ipn.Prefs
if err := json.Unmarshal(body, &p); err != nil {
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
}
return &p, nil
}
func EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
mpj, err := json.Marshal(mp)
if err != nil {
return nil, err
}
body, err := send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj))
if err != nil {
return nil, err
}
var p ipn.Prefs
if err := json.Unmarshal(body, &p); err != nil {
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
}
return &p, nil
}
func Logout(ctx context.Context) error {
_, err := send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil)
return err
}
// SetDNS adds a DNS TXT record for the given domain name, containing
// the provided TXT value. The intended use case is answering
// LetsEncrypt/ACME dns-01 challenges.
//
// The control plane will only permit SetDNS requests with very
// specific names and values. The name should be
// "_acme-challenge." + your node's MagicDNS name. It's expected that
// clients cache the certs from LetsEncrypt (or whichever CA is
// providing them) and only request new ones as needed; the control plane
// rate limits SetDNS requests.
//
// This is a low-level interface; it's expected that most Tailscale
// users use a higher level interface to getting/using TLS
// certificates.
func SetDNS(ctx context.Context, name, value string) error {
v := url.Values{}
v.Set("name", name)
v.Set("value", value)
_, err := send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
return err
}
// DialTCP connects to the host's port via Tailscale.
//
// 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 DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
connCh <- info.Conn
},
}
ctx = httptrace.WithClientTrace(ctx, &trace)
req, err := http.NewRequestWithContext(ctx, "POST", "http://local-tailscaled.sock/localapi/v0/dial", nil)
if err != nil {
return nil, err
}
req.Header = http.Header{
"Upgrade": []string{"ts-dial"},
"Connection": []string{"upgrade"},
"Dial-Host": []string{host},
"Dial-Port": []string{fmt.Sprint(port)},
}
res, err := DoLocalRequest(req)
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusSwitchingProtocols {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
}
// From here on, the underlying net.Conn is ours to use, but there
// is still a read buffer attached to it within resp.Body. So, we
// must direct I/O through resp.Body, but we can still use the
// underlying net.Conn for stuff like deadlines.
var switchedConn net.Conn
select {
case switchedConn = <-connCh:
default:
}
if switchedConn == nil {
res.Body.Close()
return nil, fmt.Errorf("httptrace didn't provide a connection")
}
rwc, ok := res.Body.(io.ReadWriteCloser)
if !ok {
res.Body.Close()
return nil, errors.New("http Transport did not provide a writable body")
}
return netutil.NewAltReadWriteCloserConn(rwc, switchedConn), nil
}
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
// It is intended to be used with netcheck to see availability of DERPs.
func CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
var derpMap tailcfg.DERPMap
res, err := send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
if err != nil {
return nil, err
}
if err = json.Unmarshal(res, &derpMap); err != nil {
return nil, fmt.Errorf("invalid derp map json: %w", err)
}
return &derpMap, nil
}
// CertPair returns a cert and private key for the provided DNS domain.
//
// It returns a cached certificate from disk if it's still valid.
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
res, err := send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
if err != nil {
return nil, nil, err
}
// with ?type=pair, the response PEM is first the one private
// key PEM block, then the cert PEM blocks.
i := mem.Index(mem.B(res), mem.S("--\n--"))
if i == -1 {
return nil, nil, fmt.Errorf("unexpected output: no delimiter")
}
i += len("--\n")
keyPEM, certPEM = res[:i], res[i:]
if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
return nil, nil, fmt.Errorf("unexpected output: key in cert")
}
return certPEM, keyPEM, nil
}
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
//
// It returns a cached certificate from disk if it's still valid.
//
// It's the right signature to use as the value of
// tls.Config.GetCertificate.
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if hi == nil || hi.ServerName == "" {
return nil, errors.New("no SNI ServerName")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
name := hi.ServerName
if !strings.Contains(name, ".") {
if v, ok := ExpandSNIName(ctx, name); ok {
name = v
}
}
certPEM, keyPEM, err := CertPair(ctx, name)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
return &cert, nil
}
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
st, err := StatusWithoutPeers(ctx)
if err != nil {
return "", false
}
for _, d := range st.CertDomains {
if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
return d, true
}
}
return "", false
}
// tailscaledConnectHint gives a little thing about why tailscaled (or
// platform equivalent) is not answering localapi connections.
//
// It ends in a punctuation. See caller.
func tailscaledConnectHint() string {
if runtime.GOOS != "linux" {
// TODO(bradfitz): flesh this out
return "not running?"
}
out, err := exec.Command("systemctl", "show", "tailscaled.service", "--no-page", "--property", "LoadState,ActiveState,SubState").Output()
if err != nil {
return "not running?"
}
// Parse:
// LoadState=loaded
// ActiveState=inactive
// SubState=dead
st := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
if k, v, ok := strings.Cut(line, "="); ok {
st[k] = strings.TrimSpace(v)
}
}
if st["LoadState"] == "loaded" &&
(st["SubState"] != "running" || st["ActiveState"] != "active") {
return "systemd tailscaled.service not running."
}
return "not running?"
errResp.Status = resp.StatusCode
return errResp
}

View File

@@ -22,13 +22,11 @@ import (
"os"
"strings"
"golang.org/x/tools/go/packages"
"tailscale.com/util/codegen"
)
var (
flagTypes = flag.String("type", "", "comma-separated list of types; required")
flagOutput = flag.String("output", "", "output file; required")
flagBuildTags = flag.String("tags", "", "compiler build tags to apply")
flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func")
)
@@ -43,30 +41,18 @@ func main() {
}
typeNames := strings.Split(*flagTypes, ",")
cfg := &packages.Config{
Mode: packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax | packages.NeedName,
Tests: false,
}
if *flagBuildTags != "" {
cfg.BuildFlags = []string{"-tags=" + *flagBuildTags}
}
pkgs, err := packages.Load(cfg, ".")
pkg, namedTypes, err := codegen.LoadTypes(*flagBuildTags, ".")
if err != nil {
log.Fatal(err)
}
if len(pkgs) != 1 {
log.Fatalf("wrong number of packages: %d", len(pkgs))
}
pkg := pkgs[0]
it := codegen.NewImportTracker(pkg.Types)
buf := new(bytes.Buffer)
imports := make(map[string]struct{})
namedTypes := codegen.NamedTypes(pkg)
for _, typeName := range typeNames {
typ, ok := namedTypes[typeName]
if !ok {
log.Fatalf("could not find type %s", typeName)
}
gen(buf, imports, typ, pkg.Types)
gen(buf, it, typ)
}
w := func(format string, args ...any) {
@@ -93,62 +79,13 @@ func main() {
w(" return false")
w("}")
}
contents := new(bytes.Buffer)
var flagArgs []string
if *flagTypes != "" {
flagArgs = append(flagArgs, "-type="+*flagTypes)
}
if *flagOutput != "" {
flagArgs = append(flagArgs, "-output="+*flagOutput)
}
if *flagBuildTags != "" {
flagArgs = append(flagArgs, "-tags="+*flagBuildTags)
}
if *flagCloneFunc {
flagArgs = append(flagArgs, "-clonefunc")
}
fmt.Fprintf(contents, header, strings.Join(flagArgs, " "), pkg.Name)
fmt.Fprintf(contents, "import (\n")
for s := range imports {
fmt.Fprintf(contents, "\t%q\n", s)
}
fmt.Fprintf(contents, ")\n\n")
contents.Write(buf.Bytes())
output := *flagOutput
if output == "" {
flag.Usage()
os.Exit(2)
}
if err := codegen.WriteFormatted(contents.Bytes(), output); err != nil {
cloneOutput := pkg.Name + "_clone.go"
if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil {
log.Fatal(err)
}
}
const header = `// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
//` + `go:generate` + ` go run tailscale.com/cmd/cloner %s
package %s
`
func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisPkg *types.Package) {
pkgQual := func(pkg *types.Package) string {
if thisPkg == pkg {
return ""
}
imports[pkg.Path()] = struct{}{}
return pkg.Name()
}
importedName := func(t types.Type) string {
return types.TypeString(t, pkgQual)
}
func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
t, ok := typ.Underlying().(*types.Struct)
if !ok {
return
@@ -169,11 +106,11 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
for i := 0; i < t.NumFields(); i++ {
fname := t.Field(i).Name()
ft := t.Field(i).Type()
if !codegen.ContainsPointers(ft) {
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
continue
}
if named, _ := ft.(*types.Named); named != nil {
if isViewType(ft) {
if codegen.IsViewType(ft) {
writef("dst.%s = src.%s", fname, fname)
continue
}
@@ -185,11 +122,16 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
switch ft := ft.Underlying().(type) {
case *types.Slice:
if codegen.ContainsPointers(ft.Elem()) {
n := importedName(ft.Elem())
n := it.QualifiedName(ft.Elem())
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
writef("for i := range dst.%s {", fname)
if _, isPtr := ft.Elem().(*types.Pointer); isPtr {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
if _, isBasic := ptr.Elem().Underlying().(*types.Basic); isBasic {
writef("\tx := *src.%s[i]", fname)
writef("\tdst.%s[i] = &x", fname)
} else {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
}
} else {
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
}
@@ -202,7 +144,7 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
writef("dst.%s = src.%s.Clone()", fname, fname)
continue
}
n := importedName(ft.Elem())
n := it.QualifiedName(ft.Elem())
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = new(%s)", fname, n)
writef("\t*dst.%s = *src.%s", fname, fname)
@@ -212,9 +154,9 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
writef("}")
case *types.Map:
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = map[%s]%s{}", fname, importedName(ft.Key()), importedName(ft.Elem()))
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(ft.Elem()))
if sliceType, isSlice := ft.Elem().(*types.Slice); isSlice {
n := importedName(sliceType.Elem())
n := it.QualifiedName(sliceType.Elem())
writef("\tfor k := range src.%s {", fname)
// use zero-length slice instead of nil to ensure
// the key is always copied.
@@ -237,20 +179,10 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
writef("return dst")
fmt.Fprintf(buf, "}\n\n")
buf.Write(codegen.AssertStructUnchanged(t, thisPkg, name, "Clone", imports))
}
func isViewType(typ types.Type) bool {
t, ok := typ.Underlying().(*types.Struct)
if !ok {
return false
}
if t.NumFields() != 1 {
return false
}
return t.Field(0).Name() == "ж"
buf.Write(codegen.AssertStructUnchanged(t, name, "Clone", it))
}
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
func hasBasicUnderlying(typ types.Type) bool {
switch typ.Underlying().(type) {
case *types.Slice, *types.Map:

View File

@@ -13,7 +13,6 @@ import (
"nhooyr.io/websocket"
"tailscale.com/derp"
"tailscale.com/derp/wsconn"
)
var counterWebSocketAccepts = expvar.NewInt("derp_websocket_accepts")
@@ -45,8 +44,8 @@ func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler {
return
}
counterWebSocketAccepts.Add(1)
wc := wsconn.New(c)
wc := websocket.NetConn(r.Context(), c, websocket.MessageBinary)
brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc))
s.Accept(wc, brw, r.RemoteAddr)
s.Accept(r.Context(), wc, brw, r.RemoteAddr)
})
}

1
cmd/gitops-pusher/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
version-cache.json

View File

@@ -0,0 +1,48 @@
# gitops-pusher
This is a small tool to help people achieve a
[GitOps](https://about.gitlab.com/topics/gitops/) workflow with Tailscale ACL
changes. This tool is intended to be used in a CI flow that looks like this:
```yaml
name: Tailscale ACL syncing
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
acls:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Go environment
uses: actions/setup-go@v3.2.0
- name: Install gitops-pusher
run: go install tailscale.com/cmd/gitops-pusher@latest
- name: Deploy ACL
if: github.event_name == 'push'
env:
TS_API_KEY: ${{ secrets.TS_API_KEY }}
TS_TAILNET: ${{ secrets.TS_TAILNET }}
run: |
~/go/bin/gitops-pusher --policy-file ./policy.hujson apply
- name: ACL tests
if: github.event_name == 'pull_request'
env:
TS_API_KEY: ${{ secrets.TS_API_KEY }}
TS_TAILNET: ${{ secrets.TS_TAILNET }}
run: |
~/go/bin/gitops-pusher --policy-file ./policy.hujson test
```
Change the value of the `--policy-file` flag to point to the policy file on
disk. Policy files should be in [HuJSON](https://github.com/tailscale/hujson)
format.

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"encoding/json"
"os"
)
// Cache contains cached information about the last time this tool was run.
//
// This is serialized to a JSON file that should NOT be checked into git.
// It should be managed with either CI cache tools or stored locally somehow. The
// exact mechanism is irrelevant as long as it is consistent.
//
// This allows gitops-pusher to detect external ACL changes. I'm not sure what to
// call this problem, so I've been calling it the "three version problem" in my
// notes. The basic problem is that at any given time we only have two versions
// of the ACL file at any given point. In order to check if there has been
// tampering of the ACL files in the admin panel, we need to have a _third_ version
// to compare against.
//
// In this case I am not storing the old ACL entirely (though that could be a
// reasonable thing to add in the future), but only its sha256sum. This allows
// us to detect if the shasum in control matches the shasum we expect, and if that
// expectation fails, then we can react accordingly.
type Cache struct {
PrevETag string // Stores the previous ETag of the ACL to allow
}
// Save persists the cache to a given file.
func (c *Cache) Save(fname string) error {
os.Remove(fname)
fout, err := os.Create(fname)
if err != nil {
return err
}
defer fout.Close()
return json.NewEncoder(fout).Encode(c)
}
// LoadCache loads the cache from a given file.
func LoadCache(fname string) (*Cache, error) {
var result Cache
fin, err := os.Open(fname)
if err != nil {
return nil, err
}
defer fin.Close()
err = json.NewDecoder(fin).Decode(&result)
if err != nil {
return nil, err
}
return &result, nil
}
// Shuck removes the first and last character of a string, analogous to
// shucking off the husk of an ear of corn.
func Shuck(s string) string {
return s[1 : len(s)-1]
}

View File

@@ -0,0 +1,373 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Command gitops-pusher allows users to use a GitOps flow for managing Tailscale ACLs.
//
// See README.md for more details.
package main
import (
"context"
"crypto/sha256"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"github.com/tailscale/hujson"
)
var (
rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError)
policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file")
cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash")
timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run")
githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)")
modifiedExternallyFailure = make(chan struct{}, 1)
)
func modifiedExternallyError() {
if *githubSyntax {
fmt.Printf("::error file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.\n", *policyFname)
} else {
fmt.Printf("The policy file was modified externally in the admin console.\n")
}
modifiedExternallyFailure <- struct{}{}
}
func apply(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
return func(ctx context.Context, args []string) error {
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
if err != nil {
return err
}
localEtag, err := sumFile(*policyFname)
if err != nil {
return err
}
if cache.PrevETag == "" {
log.Println("no previous etag found, assuming local file is correct and recording that")
cache.PrevETag = localEtag
}
log.Printf("control: %s", controlEtag)
log.Printf("local: %s", localEtag)
log.Printf("cache: %s", cache.PrevETag)
if cache.PrevETag != controlEtag {
modifiedExternallyError()
}
if controlEtag == localEtag {
cache.PrevETag = localEtag
log.Println("no update needed, doing nothing")
return nil
}
if err := applyNewACL(ctx, tailnet, apiKey, *policyFname, controlEtag); err != nil {
return err
}
cache.PrevETag = localEtag
return nil
}
}
func test(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
return func(ctx context.Context, args []string) error {
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
if err != nil {
return err
}
localEtag, err := sumFile(*policyFname)
if err != nil {
return err
}
if cache.PrevETag == "" {
log.Println("no previous etag found, assuming local file is correct and recording that")
cache.PrevETag = localEtag
}
log.Printf("control: %s", controlEtag)
log.Printf("local: %s", localEtag)
log.Printf("cache: %s", cache.PrevETag)
if cache.PrevETag != controlEtag {
modifiedExternallyError()
}
if controlEtag == localEtag {
log.Println("no updates found, doing nothing")
return nil
}
if err := testNewACLs(ctx, tailnet, apiKey, *policyFname); err != nil {
return err
}
return nil
}
}
func getChecksums(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
return func(ctx context.Context, args []string) error {
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
if err != nil {
return err
}
localEtag, err := sumFile(*policyFname)
if err != nil {
return err
}
if cache.PrevETag == "" {
log.Println("no previous etag found, assuming local file is correct and recording that")
cache.PrevETag = Shuck(localEtag)
}
log.Printf("control: %s", controlEtag)
log.Printf("local: %s", localEtag)
log.Printf("cache: %s", cache.PrevETag)
return nil
}
}
func main() {
tailnet, ok := os.LookupEnv("TS_TAILNET")
if !ok {
log.Fatal("set envvar TS_TAILNET to your tailnet's name")
}
apiKey, ok := os.LookupEnv("TS_API_KEY")
if !ok {
log.Fatal("set envvar TS_API_KEY to your Tailscale API key")
}
cache, err := LoadCache(*cacheFname)
if err != nil {
if os.IsNotExist(err) {
cache = &Cache{}
} else {
log.Fatalf("error loading cache: %v", err)
}
}
defer cache.Save(*cacheFname)
applyCmd := &ffcli.Command{
Name: "apply",
ShortUsage: "gitops-pusher [options] apply",
ShortHelp: "Pushes changes to CONTROL",
LongHelp: `Pushes changes to CONTROL`,
Exec: apply(cache, tailnet, apiKey),
}
testCmd := &ffcli.Command{
Name: "test",
ShortUsage: "gitops-pusher [options] test",
ShortHelp: "Tests ACL changes",
LongHelp: "Tests ACL changes",
Exec: test(cache, tailnet, apiKey),
}
cksumCmd := &ffcli.Command{
Name: "checksum",
ShortUsage: "Shows checksums of ACL files",
ShortHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison",
LongHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison",
Exec: getChecksums(cache, tailnet, apiKey),
}
root := &ffcli.Command{
ShortUsage: "gitops-pusher [options] <command>",
ShortHelp: "Push Tailscale ACLs to CONTROL using a GitOps workflow",
Subcommands: []*ffcli.Command{applyCmd, cksumCmd, testCmd},
FlagSet: rootFlagSet,
}
if err := root.Parse(os.Args[1:]); err != nil {
log.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
if err := root.Run(ctx); err != nil {
fmt.Println(err)
os.Exit(1)
}
if len(modifiedExternallyFailure) != 0 {
os.Exit(1)
}
}
func sumFile(fname string) (string, error) {
data, err := os.ReadFile(fname)
if err != nil {
return "", err
}
formatted, err := hujson.Format(data)
if err != nil {
return "", err
}
h := sha256.New()
_, err = h.Write(formatted)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag string) error {
fin, err := os.Open(policyFname)
if err != nil {
return err
}
defer fin.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl", tailnet), fin)
if err != nil {
return err
}
req.SetBasicAuth(apiKey, "")
req.Header.Set("Content-Type", "application/hujson")
req.Header.Set("If-Match", `"`+oldEtag+`"`)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
got := resp.StatusCode
want := http.StatusOK
if got != want {
var ate ACLTestError
err := json.NewDecoder(resp.Body).Decode(&ate)
if err != nil {
return err
}
return ate
}
return nil
}
func testNewACLs(ctx context.Context, tailnet, apiKey, policyFname string) error {
fin, err := os.Open(policyFname)
if err != nil {
return err
}
defer fin.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl/validate", tailnet), fin)
if err != nil {
return err
}
req.SetBasicAuth(apiKey, "")
req.Header.Set("Content-Type", "application/hujson")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
got := resp.StatusCode
want := http.StatusOK
if got != want {
return fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
}
var ate ACLTestError
err = json.NewDecoder(resp.Body).Decode(&ate)
if err != nil {
return err
}
if len(ate.Message) != 0 || len(ate.Data) != 0 {
return ate
}
return nil
}
var lineColMessageSplit = regexp.MustCompile(`line ([0-9]+), column ([0-9]+): (.*)$`)
type ACLTestError struct {
Message string `json:"message"`
Data []ACLTestErrorDetail `json:"data"`
}
func (ate ACLTestError) Error() string {
var sb strings.Builder
if *githubSyntax && lineColMessageSplit.MatchString(ate.Message) {
sp := lineColMessageSplit.FindStringSubmatch(ate.Message)
line := sp[1]
col := sp[2]
msg := sp[3]
fmt.Fprintf(&sb, "::error file=%s,line=%s,col=%s::%s", *policyFname, line, col, msg)
} else {
fmt.Fprintln(&sb, ate.Message)
}
fmt.Fprintln(&sb)
for _, data := range ate.Data {
fmt.Fprintf(&sb, "For user %s:\n", data.User)
for _, err := range data.Errors {
fmt.Fprintf(&sb, "- %s\n", err)
}
}
return sb.String()
}
type ACLTestErrorDetail struct {
User string `json:"user"`
Errors []string `json:"errors"`
}
func getACLETag(ctx context.Context, tailnet, apiKey string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl", tailnet), nil)
if err != nil {
return "", err
}
req.SetBasicAuth(apiKey, "")
req.Header.Set("Accept", "application/hujson")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
got := resp.StatusCode
want := http.StatusOK
if got != want {
return "", fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
}
return Shuck(resp.Header.Get("ETag")), nil
}

View File

@@ -19,10 +19,15 @@ import (
var (
goToolchain = flag.Bool("go", false, "print the supported Go toolchain git hash (a github.com/tailscale/go commit)")
goToolchainURL = flag.Bool("go-url", false, "print the URL to the tarball of the Tailscale Go toolchain")
alpine = flag.Bool("alpine", false, "print the tag of alpine docker image")
)
func main() {
flag.Parse()
if *alpine {
fmt.Println(strings.TrimSpace(ts.AlpineDockerTag))
return
}
if *goToolchain {
fmt.Println(strings.TrimSpace(ts.GoToolchainRev))
}

View File

@@ -62,6 +62,12 @@ func main() {
Hostname: *hostname,
}
// TODO(bradfitz,maisem): move this to a method on tsnet.Server probably.
if err := ts.Start(); err != nil {
log.Fatalf("Error starting tsnet.Server: %v", err)
}
localClient, _ := ts.LocalClient()
url, err := url.Parse(fmt.Sprintf("http://%s", *backendAddr))
if err != nil {
log.Fatalf("couldn't parse backend address: %v", err)
@@ -71,20 +77,20 @@ func main() {
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
modifyRequest(req)
modifyRequest(req, localClient)
}
var ln net.Listener
if *useHTTPS {
ln, err = ts.Listen("tcp", ":443")
ln = tls.NewListener(ln, &tls.Config{
GetCertificate: tailscale.GetCertificate,
GetCertificate: localClient.GetCertificate,
})
go func() {
// wait for tailscale to start before trying to fetch cert names
for i := 0; i < 60; i++ {
st, err := tailscale.Status(context.Background())
st, err := localClient.Status(context.Background())
if err != nil {
log.Printf("error retrieving tailscale status; retrying: %v", err)
} else {
@@ -100,7 +106,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
name, ok := tailscale.ExpandSNIName(context.Background(), *hostname)
name, ok := localClient.ExpandSNIName(context.Background(), *hostname)
if !ok {
log.Fatalf("can't get hostname for https redirect")
}
@@ -120,14 +126,14 @@ func main() {
log.Fatal(http.Serve(ln, proxy))
}
func modifyRequest(req *http.Request) {
func modifyRequest(req *http.Request, localClient *tailscale.LocalClient) {
// with enable_login_token set to true, we get a cookie that handles
// auth for paths that are not /login
if req.URL.Path != "/login" {
return
}
user, err := getTailscaleUser(req.Context(), req.RemoteAddr)
user, err := getTailscaleUser(req.Context(), localClient, req.RemoteAddr)
if err != nil {
log.Printf("error getting Tailscale user: %v", err)
return
@@ -137,8 +143,8 @@ func modifyRequest(req *http.Request) {
req.Header.Set("X-Webauth-Name", user.DisplayName)
}
func getTailscaleUser(ctx context.Context, ipPort string) (*tailcfg.UserProfile, error) {
whois, err := tailscale.WhoIs(ctx, ipPort)
func getTailscaleUser(ctx context.Context, localClient *tailscale.LocalClient, ipPort string) (*tailcfg.UserProfile, error) {
whois, err := localClient.WhoIs(ctx, ipPort)
if err != nil {
return nil, fmt.Errorf("failed to identify remote host: %w", err)
}

View File

@@ -9,7 +9,6 @@ import (
"errors"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
)
var bugReportCmd = &ffcli.Command{
@@ -28,7 +27,7 @@ func runBugReport(ctx context.Context, args []string) error {
default:
return errors.New("unknown argumets")
}
logMarker, err := tailscale.BugReport(ctx, note)
logMarker, err := localClient.BugReport(ctx, note)
if err != nil {
return err
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/atomicfile"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/version"
)
@@ -46,11 +45,11 @@ func runCert(ctx context.Context, args []string) error {
if certArgs.serve {
s := &http.Server{
TLSConfig: &tls.Config{
GetCertificate: tailscale.GetCertificate,
GetCertificate: localClient.GetCertificate,
},
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil && !strings.Contains(r.Host, ".") && r.Method == "GET" {
if v, ok := tailscale.ExpandSNIName(r.Context(), r.Host); ok {
if v, ok := localClient.ExpandSNIName(r.Context(), r.Host); ok {
http.Redirect(w, r, "https://"+v+r.URL.Path, http.StatusTemporaryRedirect)
return
}
@@ -64,7 +63,7 @@ func runCert(ctx context.Context, args []string) error {
if len(args) != 1 {
var hint bytes.Buffer
if st, err := tailscale.Status(ctx); err == nil {
if st, err := localClient.Status(ctx); err == nil {
if st.BackendState != ipn.Running.String() {
fmt.Fprintf(&hint, "\nTailscale is not running.\n")
} else if len(st.CertDomains) == 0 {
@@ -90,7 +89,7 @@ func runCert(ctx context.Context, args []string) error {
certArgs.certFile = domain + ".crt"
certArgs.keyFile = domain + ".key"
}
certPEM, keyPEM, err := tailscale.CertPair(ctx, domain)
certPEM, keyPEM, err := localClient.CertPair(ctx, domain)
if err != nil {
return err
}

View File

@@ -125,8 +125,12 @@ func CleanUpArgs(args []string) []string {
return out
}
var localClient tailscale.LocalClient
// Run runs the CLI. The args do not include the binary name.
func Run(args []string) (err error) {
args = CleanUpArgs(args)
if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {
args = []string{"version"}
}
@@ -193,10 +197,10 @@ change in the future.
return err
}
tailscale.TailscaledSocket = rootArgs.socket
localClient.Socket = rootArgs.socket
rootfs.Visit(func(f *flag.Flag) {
if f.Name == "socket" {
tailscale.TailscaledSocketSetExplicitly = true
localClient.UseSocketOnly = true
}
})

View File

@@ -493,14 +493,16 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
if err != nil {
t.Fatal(err)
}
applyImplicitPrefs(newPrefs, tt.curPrefs, tt.curUser)
var got string
if err := checkForAccidentalSettingReverts(newPrefs, tt.curPrefs, upCheckEnv{
upEnv := upCheckEnv{
goos: goos,
flagSet: flagSet,
curExitNodeIP: tt.curExitNodeIP,
distro: tt.distro,
}); err != nil {
user: tt.curUser,
}
applyImplicitPrefs(newPrefs, tt.curPrefs, upEnv)
var got string
if err := checkForAccidentalSettingReverts(newPrefs, tt.curPrefs, upEnv); err != nil {
got = err.Error()
}
if strings.TrimSpace(got) != tt.want {
@@ -664,11 +666,11 @@ func TestPrefsFromUpArgs(t *testing.T) {
goos: "linux",
args: upArgsT{
advertiseRoutes: "fd7a:115c:a1e0:b1a::bb:10.0.0.0/112",
netfilterMode: "off",
netfilterMode: "off",
},
want: &ipn.Prefs{
WantRunning: true,
NoSNAT: true,
WantRunning: true,
NoSNAT: true,
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"),
},
@@ -679,7 +681,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
goos: "linux",
args: upArgsT{
advertiseRoutes: "fd7a:115c:a1e0:b1a::/64",
netfilterMode: "off",
netfilterMode: "off",
},
wantErr: "fd7a:115c:a1e0:b1a::/64 4-in-6 prefix must be at least a /96",
},
@@ -688,7 +690,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
goos: "linux",
args: upArgsT{
advertiseRoutes: "fd7a:115c:a1e0:b1a:1234:5678::/112",
netfilterMode: "off",
netfilterMode: "off",
},
wantErr: "route fd7a:115c:a1e0:b1a:1234:5678::/112 contains invalid site ID 12345678; must be 0xff or less",
},
@@ -784,6 +786,10 @@ func TestUpdatePrefs(t *testing.T) {
curPrefs *ipn.Prefs
env upCheckEnv // empty goos means "linux"
// checkUpdatePrefsMutations, if non-nil, is run with the new prefs after
// updatePrefs might've mutated them (from applyImplicitPrefs).
checkUpdatePrefsMutations func(t *testing.T, newPrefs *ipn.Prefs)
wantSimpleUp bool
wantJustEditMP *ipn.MaskedPrefs
wantErrSubtr string
@@ -885,6 +891,28 @@ func TestUpdatePrefs(t *testing.T) {
},
env: upCheckEnv{backendState: "Running"},
},
{
// Issue 3808: explicitly empty --operator= should clear value.
name: "explicit_empty_operator",
flags: []string{"--operator="},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
CorpDNS: true,
AllowSingleHosts: true,
NetfilterMode: preftype.NetfilterOn,
OperatorUser: "somebody",
},
env: upCheckEnv{user: "somebody", backendState: "Running"},
wantJustEditMP: &ipn.MaskedPrefs{
OperatorUserSet: true,
WantRunningSet: true,
},
checkUpdatePrefsMutations: func(t *testing.T, prefs *ipn.Prefs) {
if prefs.OperatorUser != "" {
t.Errorf("operator sent to backend should be empty; got %q", prefs.OperatorUser)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -909,6 +937,9 @@ func TestUpdatePrefs(t *testing.T) {
}
t.Fatal(err)
}
if tt.checkUpdatePrefsMutations != nil {
tt.checkUpdatePrefsMutations(t, newPrefs)
}
if simpleUp != tt.wantSimpleUp {
t.Fatalf("simpleUp=%v, want %v", simpleUp, tt.wantSimpleUp)
}

View File

@@ -15,6 +15,8 @@ import (
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"runtime"
"strconv"
@@ -23,12 +25,14 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/control/controlhttp"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/net/tsaddr"
"tailscale.com/paths"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
var debugCmd = &ffcli.Command{
@@ -69,6 +73,11 @@ var debugCmd = &ffcli.Command{
Exec: runEnv,
ShortHelp: "print cmd/tailscale environment",
},
{
Name: "stat",
Exec: runStat,
ShortHelp: "stat a file",
},
{
Name: "hostinfo",
Exec: runHostinfo,
@@ -114,6 +123,17 @@ var debugCmd = &ffcli.Command{
Exec: runVia,
ShortHelp: "convert between site-specific IPv4 CIDRs and IPv6 'via' routes",
},
{
Name: "ts2021",
Exec: runTS2021,
ShortHelp: "debug ts2021 protocol connectivity",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("ts2021")
fs.StringVar(&ts2021Args.host, "host", "controlplane.tailscale.com", "hostname of control plane")
fs.IntVar(&ts2021Args.version, "version", int(tailcfg.CurrentCapabilityVersion), "protocol version")
return fs
})(),
},
},
}
@@ -150,7 +170,7 @@ func runDebug(ctx context.Context, args []string) error {
if out := debugArgs.cpuFile; out != "" {
usedFlag = true // TODO(bradfitz): add "profile" subcommand
log.Printf("Capturing CPU profile for %v seconds ...", debugArgs.cpuSec)
if v, err := tailscale.Profile(ctx, "profile", debugArgs.cpuSec); err != nil {
if v, err := localClient.Profile(ctx, "profile", debugArgs.cpuSec); err != nil {
return err
} else {
if err := writeProfile(out, v); err != nil {
@@ -162,7 +182,7 @@ func runDebug(ctx context.Context, args []string) error {
if out := debugArgs.memFile; out != "" {
usedFlag = true // TODO(bradfitz): add "profile" subcommand
log.Printf("Capturing memory profile ...")
if v, err := tailscale.Profile(ctx, "heap", 0); err != nil {
if v, err := localClient.Profile(ctx, "heap", 0); err != nil {
return err
} else {
if err := writeProfile(out, v); err != nil {
@@ -174,7 +194,7 @@ func runDebug(ctx context.Context, args []string) error {
if debugArgs.file != "" {
usedFlag = true // TODO(bradfitz): add "file" subcommand
if debugArgs.file == "get" {
wfs, err := tailscale.WaitingFiles(ctx)
wfs, err := localClient.WaitingFiles(ctx)
if err != nil {
fatalf("%v\n", err)
}
@@ -185,9 +205,9 @@ func runDebug(ctx context.Context, args []string) error {
}
delete := strings.HasPrefix(debugArgs.file, "delete:")
if delete {
return tailscale.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:"))
return localClient.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:"))
}
rc, size, err := tailscale.GetWaitingFile(ctx, debugArgs.file)
rc, size, err := localClient.GetWaitingFile(ctx, debugArgs.file)
if err != nil {
return err
}
@@ -222,7 +242,7 @@ var prefsArgs struct {
}
func runPrefs(ctx context.Context, args []string) error {
prefs, err := tailscale.GetPrefs(ctx)
prefs, err := localClient.GetPrefs(ctx)
if err != nil {
return err
}
@@ -256,7 +276,7 @@ func runWatchIPN(ctx context.Context, args []string) error {
}
func runDERPMap(ctx context.Context, args []string) error {
dm, err := tailscale.CurrentDERPMap(ctx)
dm, err := localClient.CurrentDERPMap(ctx)
if err != nil {
return fmt.Errorf(
"failed to get local derp map, instead `curl %s/derpmap/default`: %w", ipn.DefaultControlURL, err,
@@ -273,7 +293,7 @@ func localAPIAction(action string) func(context.Context, []string) error {
if len(args) > 0 {
return errors.New("unexpected arguments")
}
return tailscale.DebugAction(ctx, action)
return localClient.DebugAction(ctx, action)
}
}
@@ -284,6 +304,28 @@ func runEnv(ctx context.Context, args []string) error {
return nil
}
func runStat(ctx context.Context, args []string) error {
for _, a := range args {
fi, err := os.Lstat(a)
if err != nil {
fmt.Printf("%s: %v\n", a, err)
continue
}
fmt.Printf("%s: %v, %v\n", a, fi.Mode(), fi.Size())
if fi.IsDir() {
ents, _ := os.ReadDir(a)
for i, ent := range ents {
if i == 25 {
fmt.Printf(" ...\n")
break
}
fmt.Printf(" - %s\n", ent.Name())
}
}
}
return nil
}
func runHostinfo(ctx context.Context, args []string) error {
hi := hostinfo.New()
j, _ := json.MarshalIndent(hi, "", " ")
@@ -292,7 +334,7 @@ func runHostinfo(ctx context.Context, args []string) error {
}
func runDaemonGoroutines(ctx context.Context, args []string) error {
goroutines, err := tailscale.Goroutines(ctx)
goroutines, err := localClient.Goroutines(ctx)
if err != nil {
return err
}
@@ -307,7 +349,7 @@ var metricsArgs struct {
func runDaemonMetrics(ctx context.Context, args []string) error {
last := map[string]int64{}
for {
out, err := tailscale.DaemonMetrics(ctx)
out, err := localClient.DaemonMetrics(ctx)
if err != nil {
return err
}
@@ -399,3 +441,67 @@ func runVia(ctx context.Context, args []string) error {
}
return nil
}
var ts2021Args struct {
host string // "controlplane.tailscale.com"
version int // 27 or whatever
}
func runTS2021(ctx context.Context, args []string) error {
log.SetOutput(os.Stdout)
log.SetFlags(log.Ltime | log.Lmicroseconds)
machinePrivate := key.NewMachine()
var dialer net.Dialer
var keys struct {
PublicKey key.MachinePublic
}
keysURL := "https://" + ts2021Args.host + "/key?v=" + strconv.Itoa(ts2021Args.version)
log.Printf("Fetching keys from %s ...", keysURL)
req, err := http.NewRequestWithContext(ctx, "GET", keysURL, nil)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("Do: %v", err)
return err
}
if res.StatusCode != 200 {
log.Printf("Status: %v", res.Status)
return errors.New(res.Status)
}
if err := json.NewDecoder(res.Body).Decode(&keys); err != nil {
log.Printf("JSON: %v", err)
return fmt.Errorf("decoding /keys JSON: %w", err)
}
res.Body.Close()
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
log.Printf("Dial(%q, %q) ...", network, address)
c, err := dialer.DialContext(ctx, network, address)
if err != nil {
log.Printf("Dial(%q, %q) = %v", network, address, err)
} else {
log.Printf("Dial(%q, %q) = %v / %v", network, address, c.LocalAddr(), c.RemoteAddr())
}
return c, err
}
conn, err := controlhttp.Dial(ctx, net.JoinHostPort(ts2021Args.host, "80"), machinePrivate, keys.PublicKey, uint16(ts2021Args.version), dialFunc)
log.Printf("controlhttp.Dial = %p, %v", conn, err)
if err != nil {
return err
}
log.Printf("did noise handshake")
gotPeer := conn.Peer()
if gotPeer != keys.PublicKey {
log.Printf("peer = %v, want %v", gotPeer, keys.PublicKey)
return errors.New("key mismatch")
}
log.Printf("final underlying conn: %v / %v", conn.LocalAddr(), conn.RemoteAddr())
return nil
}

View File

@@ -6,10 +6,10 @@ package cli
import (
"context"
"flag"
"fmt"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
)
@@ -18,7 +18,14 @@ var downCmd = &ffcli.Command{
ShortUsage: "down",
ShortHelp: "Disconnect from Tailscale",
Exec: runDown,
Exec: runDown,
FlagSet: newDownFlagSet(),
}
func newDownFlagSet() *flag.FlagSet {
downf := newFlagSet("down")
registerAcceptRiskFlag(downf)
return downf
}
func runDown(ctx context.Context, args []string) error {
@@ -26,7 +33,13 @@ func runDown(ctx context.Context, args []string) error {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
st, err := tailscale.Status(ctx)
if isSSHOverTailscale() {
if err := presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will disable Tailscale and result in your session disconnecting.`); err != nil {
return err
}
}
st, err := localClient.Status(ctx)
if err != nil {
return fmt.Errorf("error fetching current status: %w", err)
}
@@ -34,7 +47,7 @@ func runDown(ctx context.Context, args []string) error {
fmt.Fprintf(Stderr, "Tailscale was already stopped.\n")
return nil
}
_, err = tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
_, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: false,
},

View File

@@ -24,7 +24,6 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/time/rate"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/envknob"
"tailscale.com/ipn"
@@ -157,7 +156,7 @@ func runCp(ctx context.Context, args []string) error {
if cpArgs.verbose {
log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID)
}
err := tailscale.PushFile(ctx, stableID, contentLength, name, fileContents)
err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents)
if err != nil {
return err
}
@@ -173,7 +172,7 @@ func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNode
if err != nil {
return "", false, err
}
fts, err := tailscale.FileTargets(ctx)
fts, err := localClient.FileTargets(ctx)
if err != nil {
return "", false, err
}
@@ -194,7 +193,7 @@ func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNode
// invalid file sharing target.
func fileTargetErrorDetail(ctx context.Context, ip netaddr.IP) error {
found := false
if st, err := tailscale.Status(ctx); err == nil && st.Self != nil {
if st, err := localClient.Status(ctx); err == nil && st.Self != nil {
for _, peer := range st.Peer {
for _, pip := range peer.TailscaleIPs {
if pip == ip {
@@ -261,7 +260,7 @@ func runCpTargets(ctx context.Context, args []string) error {
if len(args) > 0 {
return errors.New("invalid arguments with --targets")
}
fts, err := tailscale.FileTargets(ctx)
fts, err := localClient.FileTargets(ctx)
if err != nil {
return err
}
@@ -385,7 +384,7 @@ func openFileOrSubstitute(dir, base string, action onConflict) (*os.File, error)
}
func receiveFile(ctx context.Context, wf apitype.WaitingFile, dir string) (targetFile string, size int64, err error) {
rc, size, err := tailscale.GetWaitingFile(ctx, wf.Name)
rc, size, err := localClient.GetWaitingFile(ctx, wf.Name)
if err != nil {
return "", 0, fmt.Errorf("opening inbox file %q: %w", wf.Name, err)
}
@@ -407,7 +406,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
var err error
var errs []error
for len(errs) == 0 {
wfs, err = tailscale.WaitingFiles(ctx)
wfs, err = localClient.WaitingFiles(ctx)
if err != nil {
errs = append(errs, fmt.Errorf("getting WaitingFiles: %w", err))
break
@@ -428,7 +427,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
if len(errs) > 100 {
// Likely, everything is broken.
// Don't try to receive any more files in this batch.
errs = append(errs, fmt.Errorf("too many errors in runFileGetOneBatch(). %d files unexamined", len(wfs) - i))
errs = append(errs, fmt.Errorf("too many errors in runFileGetOneBatch(). %d files unexamined", len(wfs)-i))
break
}
writtenFile, size, err := receiveFile(ctx, wf, dir)
@@ -439,7 +438,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
if getArgs.verbose {
printf("wrote %v as %v (%d bytes)\n", wf.Name, writtenFile, size)
}
if err = tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil {
if err = localClient.DeleteWaitingFile(ctx, wf.Name); err != nil {
errs = append(errs, fmt.Errorf("deleting %q from inbox: %v", wf.Name, err))
continue
}
@@ -503,7 +502,7 @@ func wipeInbox(ctx context.Context) error {
if getArgs.wait {
return errors.New("can't use --wait with /dev/null target")
}
wfs, err := tailscale.WaitingFiles(ctx)
wfs, err := localClient.WaitingFiles(ctx)
if err != nil {
return fmt.Errorf("getting WaitingFiles: %w", err)
}
@@ -512,7 +511,7 @@ func wipeInbox(ctx context.Context) error {
if getArgs.verbose {
log.Printf("deleting %v ...", wf.Name)
}
if err := tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil {
if err := localClient.DeleteWaitingFile(ctx, wf.Name); err != nil {
return fmt.Errorf("deleting %q: %v", wf.Name, err)
}
deleted++

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
)
var idTokenCmd = &ffcli.Command{
@@ -25,7 +24,7 @@ func runIDToken(ctx context.Context, args []string) error {
return errors.New("usage: id-token <aud>")
}
tr, err := tailscale.IDToken(ctx, args[0])
tr, err := localClient.IDToken(ctx, args[0])
if err != nil {
return err
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/ipn/ipnstate"
)
@@ -59,7 +58,7 @@ func runIP(ctx context.Context, args []string) error {
if !v4 && !v6 {
v4, v6 = true, true
}
st, err := tailscale.Status(ctx)
st, err := localClient.Status(ctx)
if err != nil {
return err
}

View File

@@ -10,7 +10,6 @@ import (
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
)
var logoutCmd = &ffcli.Command{
@@ -30,5 +29,5 @@ func runLogout(ctx context.Context, args []string) error {
if len(args) > 0 {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
return tailscale.Logout(ctx)
return localClient.Logout(ctx)
}

View File

@@ -13,7 +13,6 @@ import (
"strconv"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
)
var ncCmd = &ffcli.Command{
@@ -24,7 +23,7 @@ var ncCmd = &ffcli.Command{
}
func runNC(ctx context.Context, args []string) error {
st, err := tailscale.Status(ctx)
st, err := localClient.Status(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
@@ -45,7 +44,7 @@ func runNC(ctx context.Context, args []string) error {
}
// TODO(bradfitz): also add UDP too, via flag?
c, err := tailscale.DialTCP(ctx, hostOrIP, uint16(port))
c, err := localClient.DialTCP(ctx, hostOrIP, uint16(port))
if err != nil {
return fmt.Errorf("Dial(%q, %v): %w", hostOrIP, port, err)
}

View File

@@ -18,7 +18,6 @@ import (
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/net/netcheck"
@@ -63,7 +62,7 @@ func runNetcheck(ctx context.Context, args []string) error {
fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface")
}
dm, err := tailscale.CurrentDERPMap(ctx)
dm, err := localClient.CurrentDERPMap(ctx)
noRegions := dm != nil && len(dm.Regions) == 0
if noRegions {
log.Printf("No DERP map from tailscaled; using default.")
@@ -127,8 +126,10 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
printf("\t* IPv6: yes, %v\n", report.GlobalV6)
} else if report.IPv6 {
printf("\t* IPv6: (no addr found)\n")
} else if report.OSHasIPv6 {
printf("\t* IPv6: no, but OS has support\n")
} else {
printf("\t* IPv6: no\n")
printf("\t* IPv6: no, unavailable in OS\n")
}
printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
printf("\t* HairPinning: %v\n", report.HairPinning)

View File

@@ -16,9 +16,9 @@ import (
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"inet.af/netaddr"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)
var pingCmd = &ffcli.Command{
@@ -27,7 +27,7 @@ var pingCmd = &ffcli.Command{
ShortHelp: "Ping a host at the Tailscale layer, see how it routed",
LongHelp: strings.TrimSpace(`
The 'tailscale ping' command pings a peer node at the Tailscale layer
The 'tailscale ping' command pings a peer node from the Tailscale layer
and reports which route it took for each response. The first ping or
so will likely go over DERP (Tailscale's TCP relay protocol) while NAT
traversal finds a direct path through.
@@ -49,7 +49,9 @@ relay node.
fs := newFlagSet("ping")
fs.BoolVar(&pingArgs.verbose, "verbose", false, "verbose output")
fs.BoolVar(&pingArgs.untilDirect, "until-direct", true, "stop once a direct path is established")
fs.BoolVar(&pingArgs.tsmp, "tsmp", false, "do a TSMP-level ping (through IP + wireguard, but not involving host OS stack)")
fs.BoolVar(&pingArgs.tsmp, "tsmp", false, "do a TSMP-level ping (through WireGuard, but not either host OS stack)")
fs.BoolVar(&pingArgs.icmp, "icmp", false, "do a ICMP-level ping (through WireGuard, but not the local host OS stack)")
fs.BoolVar(&pingArgs.peerAPI, "peerapi", false, "try hitting the peer's peerapi HTTP server")
fs.IntVar(&pingArgs.num, "c", 10, "max number of pings to send")
fs.DurationVar(&pingArgs.timeout, "timeout", 5*time.Second, "timeout before giving up on a ping")
return fs
@@ -61,11 +63,26 @@ var pingArgs struct {
untilDirect bool
verbose bool
tsmp bool
icmp bool
peerAPI bool
timeout time.Duration
}
func pingType() tailcfg.PingType {
if pingArgs.tsmp {
return tailcfg.PingTSMP
}
if pingArgs.icmp {
return tailcfg.PingICMP
}
if pingArgs.peerAPI {
return tailcfg.PingPeerAPI
}
return tailcfg.PingDisco
}
func runPing(ctx context.Context, args []string) error {
st, err := tailscale.Status(ctx)
st, err := localClient.Status(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
@@ -75,24 +92,10 @@ func runPing(ctx context.Context, args []string) error {
os.Exit(1)
}
c, bc, ctx, cancel := connect(ctx)
defer cancel()
if len(args) != 1 || args[0] == "" {
return errors.New("usage: ping <hostname-or-IP>")
}
var ip string
prc := make(chan *ipnstate.PingResult, 1)
bc.SetNotifyCallback(func(n ipn.Notify) {
if n.ErrMessage != nil {
fatalf("Notify.ErrMessage: %v", *n.ErrMessage)
}
if pr := n.PingResult; pr != nil && pr.IP == ip {
prc <- pr
}
})
pumpErr := make(chan error, 1)
go func() { pumpErr <- pump(ctx, bc, c) }()
hostOrIP := args[0]
ip, self, err := tailscaleIPFromArg(ctx, hostOrIP)
@@ -112,48 +115,57 @@ func runPing(ctx context.Context, args []string) error {
anyPong := false
for {
n++
bc.Ping(ip, pingArgs.tsmp)
timer := time.NewTimer(pingArgs.timeout)
select {
case <-timer.C:
printf("timeout waiting for ping reply\n")
case err := <-pumpErr:
return err
case pr := <-prc:
timer.Stop()
if pr.Err != "" {
if pr.IsLocalIP {
outln(pr.Err)
ctx, cancel := context.WithTimeout(ctx, pingArgs.timeout)
pr, err := localClient.Ping(ctx, netaddr.MustParseIP(ip), pingType())
cancel()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
printf("ping %q timed out\n", ip)
if n == pingArgs.num {
if !anyPong {
return errors.New("no reply")
}
return nil
}
return errors.New(pr.Err)
continue
}
latency := time.Duration(pr.LatencySeconds * float64(time.Second)).Round(time.Millisecond)
via := pr.Endpoint
if pr.DERPRegionID != 0 {
via = fmt.Sprintf("DERP(%s)", pr.DERPRegionCode)
}
if pingArgs.tsmp {
// TODO(bradfitz): populate the rest of ipnstate.PingResult for TSMP queries?
// For now just say it came via TSMP.
via = "TSMP"
}
anyPong = true
extra := ""
if pr.PeerAPIPort != 0 {
extra = fmt.Sprintf(", %d", pr.PeerAPIPort)
}
printf("pong from %s (%s%s) via %v in %v\n", pr.NodeName, pr.NodeIP, extra, via, latency)
if pingArgs.tsmp {
return nil
}
if pr.Endpoint != "" && pingArgs.untilDirect {
return nil
}
time.Sleep(time.Second)
case <-ctx.Done():
return ctx.Err()
return err
}
if pr.Err != "" {
if pr.IsLocalIP {
outln(pr.Err)
return nil
}
return errors.New(pr.Err)
}
latency := time.Duration(pr.LatencySeconds * float64(time.Second)).Round(time.Millisecond)
via := pr.Endpoint
if pr.DERPRegionID != 0 {
via = fmt.Sprintf("DERP(%s)", pr.DERPRegionCode)
}
if via == "" {
// TODO(bradfitz): populate the rest of ipnstate.PingResult for TSMP queries?
// For now just say which protocol it used.
via = string(pingType())
}
if pingArgs.peerAPI {
printf("hit peerapi of %s (%s) at %s in %s\n", pr.NodeIP, pr.NodeName, pr.PeerAPIURL, latency)
return nil
}
anyPong = true
extra := ""
if pr.PeerAPIPort != 0 {
extra = fmt.Sprintf(", %d", pr.PeerAPIPort)
}
printf("pong from %s (%s%s) via %v in %v\n", pr.NodeName, pr.NodeIP, extra, via, latency)
if pingArgs.tsmp || pingArgs.icmp {
return nil
}
if pr.Endpoint != "" && pingArgs.untilDirect {
return nil
}
time.Sleep(time.Second)
if n == pingArgs.num {
if !anyPong {
return errors.New("no reply")
@@ -173,7 +185,7 @@ func tailscaleIPFromArg(ctx context.Context, hostOrIP string) (ip string, self b
}
// Otherwise, try to resolve it first from the network peer list.
st, err := tailscale.Status(ctx)
st, err := localClient.Status(ctx)
if err != nil {
return "", false, err
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cli
import (
"errors"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
var (
riskTypes []string
acceptedRisks string
riskLoseSSH = registerRiskType("lose-ssh")
)
func registerRiskType(riskType string) string {
riskTypes = append(riskTypes, riskType)
return riskType
}
// registerAcceptRiskFlag registers the --accept-risk flag. Accepted risks are accounted for
// in presentRiskToUser.
func registerAcceptRiskFlag(f *flag.FlagSet) {
f.StringVar(&acceptedRisks, "accept-risk", "", "accept risk and skip confirmation for risk types: "+strings.Join(riskTypes, ","))
}
// riskAccepted reports whether riskType is in acceptedRisks.
func riskAccepted(riskType string) bool {
for _, r := range strings.Split(acceptedRisks, ",") {
if r == riskType {
return true
}
}
return false
}
var errAborted = errors.New("aborted, no changes made")
// riskAbortTimeSeconds is the number of seconds to wait after displaying the
// risk message before continuing with the operation.
// It is used by the presentRiskToUser function below.
const riskAbortTimeSeconds = 5
// presentRiskToUser displays the risk message and waits for the user to
// cancel. It returns errorAborted if the user aborts.
func presentRiskToUser(riskType, riskMessage string) error {
if riskAccepted(riskType) {
return nil
}
fmt.Println(riskMessage)
fmt.Printf("To skip this warning, use --accept-risk=%s\n", riskType)
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, syscall.SIGINT)
var msgLen int
for left := riskAbortTimeSeconds; left > 0; left-- {
msg := fmt.Sprintf("\rContinuing in %d seconds...", left)
msgLen = len(msg)
fmt.Print(msg)
select {
case <-interrupt:
fmt.Printf("\r%s\r", strings.Repeat(" ", msgLen+1))
return errAborted
case <-time.After(time.Second):
continue
}
}
fmt.Printf("\r%s\r", strings.Repeat(" ", msgLen))
return errAborted
}

View File

@@ -11,19 +11,17 @@ import (
"fmt"
"log"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"syscall"
"github.com/alessio/shellescape"
"github.com/peterbourgon/ff/v3/ffcli"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/tsaddr"
"tailscale.com/version"
)
var sshCmd = &ffcli.Command{
@@ -34,6 +32,9 @@ var sshCmd = &ffcli.Command{
}
func runSSH(ctx context.Context, args []string) error {
if runtime.GOOS == "darwin" && version.IsSandboxedMacOS() && !envknob.UseWIPCode() {
return errors.New("The 'tailscale ssh' subcommand is not available on sandboxed macOS builds.\nUse the regular 'ssh' client instead.")
}
if len(args) == 0 {
return errors.New("usage: ssh [user@]<host>")
}
@@ -48,7 +49,7 @@ func runSSH(ctx context.Context, args []string) error {
username = lu.Username
}
st, err := tailscale.Status(ctx)
st, err := localClient.Status(ctx)
if err != nil {
return err
}
@@ -61,7 +62,7 @@ func runSSH(ctx context.Context, args []string) error {
hostForSSH = v
}
ssh, err := exec.LookPath("ssh")
ssh, err := findSSH()
if err != nil {
// TODO(bradfitz): use Go's crypto/ssh client instead
// of failing. But for now:
@@ -76,55 +77,49 @@ func runSSH(ctx context.Context, args []string) error {
return err
}
argv := append([]string{
ssh,
argv := []string{ssh}
if envknob.Bool("TS_DEBUG_SSH_EXEC") {
argv = append(argv, "-vvv")
}
argv = append(argv,
// Only trust SSH hosts that we know about.
"-o", fmt.Sprintf("UserKnownHostsFile %s",
shellescape.Quote(knownHostsFile),
),
"-o", fmt.Sprintf("UserKnownHostsFile %q", knownHostsFile),
"-o", "UpdateHostKeys no",
"-o", "StrictHostKeyChecking yes",
)
"-o", fmt.Sprintf("ProxyCommand %s --socket=%s nc %%h %%p",
shellescape.Quote(tailscaleBin),
shellescape.Quote(rootArgs.socket),
),
// Explicitly rebuild the user@host argument rather than
// passing it through. In general, the use of OpenSSH's ssh
// binary is a crutch for now. We don't want to be
// Hyrum-locked into passing through all OpenSSH flags to the
// OpenSSH client forever. We try to make our flags and args
// be compatible, but only a subset. The "tailscale ssh"
// command should be a simple and portable one. If they want
// to use a different one, we'll later be making stock ssh
// work well by default too. (doing things like automatically
// setting known_hosts, etc)
username + "@" + hostForSSH,
}, argRest...)
if runtime.GOOS == "windows" {
// Don't use syscall.Exec on Windows.
cmd := exec.Command(ssh, argv[1:]...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
var ee *exec.ExitError
err := cmd.Run()
if errors.As(err, &ee) {
os.Exit(ee.ExitCode())
}
return err
// TODO(bradfitz): nc is currently broken on macOS:
// https://github.com/tailscale/tailscale/issues/4529
// So don't use it for now. MagicDNS is usually working on macOS anyway
// and they're not in userspace mode, so 'nc' isn't very useful.
if runtime.GOOS != "darwin" {
argv = append(argv,
"-o", fmt.Sprintf("ProxyCommand %q --socket=%q nc %%h %%p",
tailscaleBin,
rootArgs.socket,
))
}
// Explicitly rebuild the user@host argument rather than
// passing it through. In general, the use of OpenSSH's ssh
// binary is a crutch for now. We don't want to be
// Hyrum-locked into passing through all OpenSSH flags to the
// OpenSSH client forever. We try to make our flags and args
// be compatible, but only a subset. The "tailscale ssh"
// command should be a simple and portable one. If they want
// to use a different one, we'll later be making stock ssh
// work well by default too. (doing things like automatically
// setting known_hosts, etc)
argv = append(argv, username+"@"+hostForSSH)
argv = append(argv, argRest...)
if envknob.Bool("TS_DEBUG_SSH_EXEC") {
log.Printf("Running: %q, %q ...", ssh, argv)
}
if err := syscall.Exec(ssh, argv, os.Environ()); err != nil {
return err
}
return errors.New("unreachable")
return execSSH(ssh, argv)
}
func writeKnownHosts(st *ipnstate.Status) (knownHostsFile string, err error) {
@@ -188,3 +183,28 @@ func nodeDNSNameFromArg(st *ipnstate.Status, arg string) (dnsName string, ok boo
}
return "", false
}
// getSSHClientEnvVar returns the "SSH_CLIENT" environment variable
// for the current process group, if any.
var getSSHClientEnvVar = func() string {
return ""
}
// isSSHOverTailscale checks if the invocation is in a SSH session over Tailscale.
// It is used to detect if the user is about to take an action that might result in them
// disconnecting from the machine (e.g. disabling SSH)
func isSSHOverTailscale() bool {
sshClient := getSSHClientEnvVar()
if sshClient == "" {
return false
}
ipStr, _, ok := strings.Cut(sshClient, " ")
if !ok {
return false
}
ip, err := netaddr.ParseIP(ipStr)
if err != nil {
return false
}
return tsaddr.IsTailscaleIP(ip)
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !js && !windows
// +build !js,!windows
package cli
import (
"errors"
"os"
"os/exec"
"syscall"
)
func findSSH() (string, error) {
return exec.LookPath("ssh")
}
func execSSH(ssh string, argv []string) error {
if err := syscall.Exec(ssh, argv, os.Environ()); err != nil {
return err
}
return errors.New("unreachable")
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cli
import (
"errors"
)
func findSSH() (string, error) {
return "", errors.New("Not implemented")
}
func execSSH(ssh string, argv []string) error {
return errors.New("Not implemented")
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cli
import (
"errors"
"os"
"os/exec"
"path/filepath"
)
func findSSH() (string, error) {
// use C:\Windows\System32\OpenSSH\ssh.exe since unexpected behavior
// occured with ssh.exe provided by msys2/cygwin and other environments.
if systemRoot := os.Getenv("SystemRoot"); systemRoot != "" {
exe := filepath.Join(systemRoot, "System32", "OpenSSH", "ssh.exe")
if st, err := os.Stat(exe); err == nil && !st.IsDir() {
return exe, nil
}
}
return exec.LookPath("ssh")
}
func execSSH(ssh string, argv []string) error {
// Don't use syscall.Exec on Windows, it's not fully implemented.
cmd := exec.Command(ssh, argv[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
var ee *exec.ExitError
err := cmd.Run()
if errors.As(err, &ee) {
os.Exit(ee.ExitCode())
}
return err
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !js && !windows
// +build !js,!windows
package cli
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strconv"
"golang.org/x/sys/unix"
)
func init() {
getSSHClientEnvVar = func() string {
if os.Getenv("SUDO_USER") == "" {
// No sudo, just check the env.
return os.Getenv("SSH_CLIENT")
}
if runtime.GOOS != "linux" {
// TODO(maisem): implement this for other platforms. It's not clear
// if there is a way to get the environment for a given process on
// darwin and bsd.
return ""
}
// SID is the session ID of the user's login session.
// It is also the process ID of the original shell that the user logged in with.
// We only need to check the environment of that process.
sid, err := unix.Getsid(os.Getpid())
if err != nil {
return ""
}
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(sid), "environ"))
if err != nil {
return ""
}
prefix := []byte("SSH_CLIENT=")
for _, env := range bytes.Split(b, []byte{0}) {
if bytes.HasPrefix(env, prefix) {
return string(env[len(prefix):])
}
}
return ""
}
}

View File

@@ -19,7 +19,6 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"github.com/toqueteos/webbrowser"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/interfaces"
@@ -73,9 +72,9 @@ func runStatus(ctx context.Context, args []string) error {
if len(args) > 0 {
return errors.New("unexpected non-flag arguments to 'tailscale status'")
}
getStatus := tailscale.Status
getStatus := localClient.Status
if !statusArgs.peers {
getStatus = tailscale.StatusWithoutPeers
getStatus = localClient.StatusWithoutPeers
}
st, err := getStatus(ctx)
if err != nil {
@@ -115,7 +114,7 @@ func runStatus(ctx context.Context, args []string) error {
http.NotFound(w, r)
return
}
st, err := tailscale.Status(ctx)
st, err := localClient.Status(ctx)
if err != nil {
http.Error(w, err.Error(), 500)
return
@@ -129,12 +128,8 @@ func runStatus(ctx context.Context, args []string) error {
return err
}
description, ok := isRunningOrStarting(st)
if !ok {
outln(description)
os.Exit(1)
}
// print health check information prior to checking LocalBackend state as
// it may provide an explanation to the user if we choose to exit early
if len(st.Health) > 0 {
printf("# Health check:\n")
for _, m := range st.Health {
@@ -143,6 +138,12 @@ func runStatus(ctx context.Context, args []string) error {
outln()
}
description, ok := isRunningOrStarting(st)
if !ok {
outln(description)
os.Exit(1)
}
var buf bytes.Buffer
f := func(format string, a ...any) { fmt.Fprintf(&buf, format, a...) }
printPS := func(ps *ipnstate.PeerStatus) {

View File

@@ -19,12 +19,12 @@ import (
"sort"
"strings"
"sync"
"time"
shellquote "github.com/kballard/go-shellquote"
"github.com/peterbourgon/ff/v3/ffcli"
qrcode "github.com/skip2/go-qrcode"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/tsaddr"
@@ -52,7 +52,7 @@ down").
If flags are specified, the flags must be the complete set of desired
settings. An error is returned if any setting would be changed as a
result of an unspecified flag's default value, unless the --reset flag
is also used. (The flags --authkey, --force-reauth, and --qr are not
is also used. (The flags --auth-key, --force-reauth, and --qr are not
considered settings that need to be re-specified when modifying
settings.)
`),
@@ -115,6 +115,8 @@ func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet {
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)")
}
upf.DurationVar(&upArgs.timeout, "timeout", 0, "maximum amount of time to wait for tailscaled to enter a Running state; default (0s) blocks forever")
registerAcceptRiskFlag(upf)
return upf
}
@@ -147,6 +149,7 @@ type upArgsT struct {
hostname string
opUser string
json bool
timeout time.Duration
}
func (a upArgsT) getAuthKey() (string, error) {
@@ -359,7 +362,7 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
// without changing any settings.
func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, justEditMP *ipn.MaskedPrefs, err error) {
if !env.upArgs.reset {
applyImplicitPrefs(prefs, curPrefs, env.user)
applyImplicitPrefs(prefs, curPrefs, env)
if err := checkForAccidentalSettingReverts(prefs, curPrefs, env); err != nil {
return false, nil, err
@@ -401,12 +404,12 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
return simpleUp, justEditMP, nil
}
func runUp(ctx context.Context, args []string) error {
func runUp(ctx context.Context, args []string) (retErr error) {
if len(args) > 0 {
fatalf("too many non-flag arguments: %q", args)
}
st, err := tailscale.Status(ctx)
st, err := localClient.Status(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
@@ -447,12 +450,12 @@ func runUp(ctx context.Context, args []string) error {
}
if len(prefs.AdvertiseRoutes) > 0 {
if err := tailscale.CheckIPForwarding(context.Background()); err != nil {
if err := localClient.CheckIPForwarding(context.Background()); err != nil {
warnf("%v", err)
}
}
curPrefs, err := tailscale.GetPrefs(ctx)
curPrefs, err := localClient.GetPrefs(ctx)
if err != nil {
return err
}
@@ -466,12 +469,30 @@ func runUp(ctx context.Context, args []string) error {
backendState: st.BackendState,
curExitNodeIP: exitNodeIP(curPrefs, st),
}
if upArgs.runSSH != curPrefs.RunSSH && isSSHOverTailscale() {
if upArgs.runSSH {
err = presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will reroute SSH traffic to Tailscale SSH and will result in your session disconnecting.`)
} else {
err = presentRiskToUser(riskLoseSSH, `You are connected using Tailscale SSH; this action will result in your session disconnecting.`)
}
if err != nil {
return err
}
}
defer func() {
if retErr == nil {
checkSSHUpWarnings(ctx)
}
}()
simpleUp, justEditMP, err := updatePrefs(prefs, curPrefs, env)
if err != nil {
fatalf("%s", err)
}
if justEditMP != nil {
_, err := tailscale.EditPrefs(ctx, justEditMP)
_, err := localClient.EditPrefs(ctx, justEditMP)
return err
}
@@ -582,7 +603,7 @@ func runUp(ctx context.Context, args []string) error {
// Special case: bare "tailscale up" means to just start
// running, if there's ever been a login.
if simpleUp {
_, err := tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: true,
},
@@ -592,7 +613,7 @@ func runUp(ctx context.Context, args []string) error {
return err
}
} else {
if err := tailscale.CheckPrefs(ctx, prefs); err != nil {
if err := localClient.CheckPrefs(ctx, prefs); err != nil {
return err
}
@@ -633,6 +654,12 @@ func runUp(ctx context.Context, args []string) error {
// need to prioritize reads from 'running' if it's
// readable; its send does happen before the pump mechanism
// shuts down. (Issue 2333)
var timeoutCh <-chan time.Time
if upArgs.timeout > 0 {
timeoutTimer := time.NewTimer(upArgs.timeout)
defer timeoutTimer.Stop()
timeoutCh = timeoutTimer.C
}
select {
case <-running:
return nil
@@ -650,6 +677,30 @@ func runUp(ctx context.Context, args []string) error {
default:
}
return err
case <-timeoutCh:
return errors.New(`timeout waiting for Tailscale service to enter a Running state; check health with "tailscale status"`)
}
}
func checkSSHUpWarnings(ctx context.Context) {
if !upArgs.runSSH {
return
}
st, err := localClient.Status(ctx)
if err != nil {
// Ignore. Don't spam more.
return
}
if len(st.Health) == 0 {
return
}
if len(st.Health) == 1 && strings.Contains(st.Health[0], "SSH") {
printf("%s\n", st.Health[0])
return
}
printf("# Health check:\n")
for _, m := range st.Health {
printf(" - %s\n", m)
}
}
@@ -706,7 +757,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":
case "auth-key", "force-reauth", "reset", "qr", "json", "timeout", "accept-risk":
return true
}
return false
@@ -830,13 +881,20 @@ func checkForAccidentalSettingReverts(newPrefs, curPrefs *ipn.Prefs, env upCheck
return errors.New(sb.String())
}
// applyImplicitPrefs mutates prefs to add implicit preferences. Currently
// this is just the operator user, which only needs to be set if it doesn't
// applyImplicitPrefs mutates prefs to add implicit preferences for the user operator.
// If the operator flag is passed no action is taken, otherwise this only needs to be set if it doesn't
// match the current user.
//
// curUser is os.Getenv("USER"). It's pulled out for testability.
func applyImplicitPrefs(prefs, oldPrefs *ipn.Prefs, curUser string) {
if prefs.OperatorUser == "" && oldPrefs.OperatorUser == curUser {
func applyImplicitPrefs(prefs, oldPrefs *ipn.Prefs, env upCheckEnv) {
explicitOperator := false
env.flagSet.Visit(func(f *flag.Flag) {
if f.Name == "operator" {
explicitOperator = true
}
})
if prefs.OperatorUser == "" && oldPrefs.OperatorUser == env.user && !explicitOperator {
prefs.OperatorUser = oldPrefs.OperatorUser
}
}

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/version"
)
@@ -41,7 +40,7 @@ func runVersion(ctx context.Context, args []string) error {
printf("Client: %s\n", version.String())
st, err := tailscale.StatusWithoutPeers(ctx)
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return err
}

View File

@@ -28,7 +28,6 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/preftype"
@@ -209,12 +208,20 @@ func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
return "", nil, err
}
token, err := r.Cookie("qtoken")
if err != nil {
return "", nil, err
if err == nil {
return qnapAuthnQtoken(r, user.Value, token.Value)
}
sid, err := r.Cookie("NAS_SID")
if err == nil {
return qnapAuthnSid(r, user.Value, sid.Value)
}
return "", nil, fmt.Errorf("not authenticated by any mechanism")
}
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
query := url.Values{
"qtoken": []string{token.Value},
"user": []string{user.Value},
"qtoken": []string{token},
"user": []string{user},
}
u := url.URL{
Scheme: r.URL.Scheme,
@@ -222,7 +229,26 @@ func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
Path: "/cgi-bin/authLogin.cgi",
RawQuery: query.Encode(),
}
resp, err := http.Get(u.String())
return qnapAuthnFinish(user, u.String())
}
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
query := url.Values{
"sid": []string{sid},
}
u := url.URL{
Scheme: r.URL.Scheme,
Host: r.URL.Host,
Path: "/cgi-bin/authLogin.cgi",
RawQuery: query.Encode(),
}
return qnapAuthnFinish(user, u.String())
}
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
resp, err := http.Get(url)
if err != nil {
return "", nil, err
}
@@ -238,7 +264,7 @@ func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
if authResp.AuthPassed == 0 {
return "", nil, fmt.Errorf("not authenticated")
}
return user.Value, authResp, nil
return user, authResp, nil
}
func synoAuthn() (string, error) {
@@ -318,7 +344,7 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
prefs, err := tailscale.GetPrefs(r.Context())
prefs, err := localClient.GetPrefs(r.Context())
if err != nil && !postData.Reauthenticate {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
@@ -348,12 +374,12 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
return
}
st, err := tailscale.Status(r.Context())
st, err := localClient.Status(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
prefs, err := tailscale.GetPrefs(r.Context())
prefs, err := localClient.GetPrefs(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -406,7 +432,7 @@ func tailscaleUp(ctx context.Context, prefs *ipn.Prefs, forceReauth bool) (authU
prefs.NetfilterMode = preftype.NetfilterOff
}
st, err := tailscale.Status(ctx)
st, err := localClient.Status(ctx)
if err != nil {
return "", fmt.Errorf("can't fetch status: %v", err)
}

View File

@@ -1,6 +1,5 @@
tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/depaware)
github.com/alessio/shellescape from tailscale.com/cmd/tailscale/cli
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/negotiate+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
@@ -9,7 +8,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli
L github.com/klauspost/compress/flate from nhooyr.io/websocket
github.com/klauspost/compress/flate from nhooyr.io/websocket
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
@@ -32,39 +31,41 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
go4.org/unsafe/assume-no-moving-gc from go4.org/intern
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
inet.af/netaddr from tailscale.com/cmd/tailscale/cli+
L nhooyr.io/websocket from tailscale.com/derp/derphttp+
L nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
L nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
nhooyr.io/websocket from tailscale.com/derp/derphttp+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
tailscale.com from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+
tailscale.com/client/tailscale/apitype from tailscale.com/cmd/tailscale/cli+
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/control/controlbase from tailscale.com/control/controlhttp
tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli
tailscale.com/control/controlknobs from tailscale.com/net/portmapper
tailscale.com/derp from tailscale.com/derp/derphttp
tailscale.com/derp/derphttp from tailscale.com/net/netcheck
L tailscale.com/derp/wsconn from tailscale.com/derp/derphttp
tailscale.com/disco from tailscale.com/derp
tailscale.com/envknob from tailscale.com/cmd/tailscale/cli+
tailscale.com/hostinfo from tailscale.com/net/interfaces+
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+
tailscale.com/ipn/ipnstate from tailscale.com/cmd/tailscale/cli+
💣 tailscale.com/metrics from tailscale.com/derp
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
tailscale.com/net/dnscache from tailscale.com/derp/derphttp+
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp
tailscale.com/net/flowtrack from tailscale.com/wgengine/filter+
💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/netcheck from tailscale.com/cmd/tailscale/cli
tailscale.com/net/neterror from tailscale.com/net/netcheck+
tailscale.com/net/netknob from tailscale.com/net/netns
tailscale.com/net/netns from tailscale.com/derp/derphttp+
tailscale.com/net/netutil from tailscale.com/client/tailscale
tailscale.com/net/netutil from tailscale.com/client/tailscale+
tailscale.com/net/packet from tailscale.com/wgengine/filter
tailscale.com/net/portmapper from tailscale.com/net/netcheck+
tailscale.com/net/stun from tailscale.com/net/netcheck
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp+
tailscale.com/net/tsaddr from tailscale.com/net/interfaces+
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
💣 tailscale.com/paths from tailscale.com/cmd/tailscale/cli+
tailscale.com/paths from tailscale.com/cmd/tailscale/cli+
tailscale.com/safesocket from tailscale.com/cmd/tailscale/cli+
tailscale.com/syncs from tailscale.com/net/interfaces+
tailscale.com/tailcfg from tailscale.com/cmd/tailscale/cli+
@@ -84,23 +85,25 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/types/structs from tailscale.com/ipn+
tailscale.com/types/views from tailscale.com/tailcfg+
tailscale.com/util/clientmetric from tailscale.com/net/netcheck+
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
W tailscale.com/util/endian from tailscale.com/net/netns
tailscale.com/util/groupmember from tailscale.com/cmd/tailscale/cli
tailscale.com/util/lineread from tailscale.com/net/interfaces+
W tailscale.com/util/winutil from tailscale.com/hostinfo
W 💣 tailscale.com/util/winutil/vss from tailscale.com/util/winutil
tailscale.com/util/singleflight from tailscale.com/net/dnscache
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/cmd/tailscale/cli+
tailscale.com/version/distro from tailscale.com/cmd/tailscale/cli+
tailscale.com/wgengine/filter from tailscale.com/types/netmap
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box
golang.org/x/crypto/blake2s from tailscale.com/control/controlbase
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
golang.org/x/crypto/chacha20poly1305 from crypto/tls
golang.org/x/crypto/chacha20poly1305 from crypto/tls+
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
golang.org/x/crypto/curve25519 from crypto/tls+
golang.org/x/crypto/hkdf from crypto/tls
golang.org/x/crypto/hkdf from crypto/tls+
golang.org/x/crypto/nacl/box from tailscale.com/types/key
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
@@ -113,7 +116,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from net+
golang.org/x/sync/errgroup from tailscale.com/derp+
golang.org/x/sync/singleflight from tailscale.com/net/dnscache
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+
LD golang.org/x/sys/unix from tailscale.com/net/netns+
W golang.org/x/sys/windows from golang.org/x/sys/windows/registry+
@@ -200,7 +202,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
reflect from crypto/x509+
regexp from github.com/tailscale/goupnp/httpu+
regexp/syntax from regexp
runtime/debug from golang.org/x/sync/singleflight+
runtime/debug from tailscale.com/util/singleflight+
sort from compress/flate+
strconv from compress/flate+
strings from bufio+

View File

@@ -17,7 +17,6 @@ import (
func main() {
args := os.Args[1:]
args = cli.CleanUpArgs(args)
if name, _ := os.Executable(); strings.HasSuffix(filepath.Base(name), ".cgi") {
args = []string{"web", "-cgi"}
}

View File

@@ -76,12 +76,15 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
github.com/klauspost/compress from github.com/klauspost/compress/zstd
L github.com/klauspost/compress/flate from nhooyr.io/websocket
github.com/klauspost/compress/flate from nhooyr.io/websocket
github.com/klauspost/compress/fse from github.com/klauspost/compress/huff0
github.com/klauspost/compress/huff0 from github.com/klauspost/compress/zstd
github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/zstd
github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd
github.com/klauspost/compress/zstd from tailscale.com/smallzstd
github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd
github.com/kortschak/wol from tailscale.com/ipn/ipnlocal
LD github.com/kr/fs from github.com/pkg/sftp
L github.com/mdlayher/genetlink from tailscale.com/net/tstun
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
@@ -89,6 +92,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
W github.com/pkg/errors from github.com/tailscale/certstore
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
LD github.com/tailscale/golang-x-crypto/chacha20 from github.com/tailscale/golang-x-crypto/ssh
LD 💣 github.com/tailscale/golang-x-crypto/internal/subtle from github.com/tailscale/golang-x-crypto/chacha20
@@ -109,7 +114,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
💣 go4.org/intern from inet.af/netaddr
💣 go4.org/mem from tailscale.com/client/tailscale+
💣 go4.org/mem from tailscale.com/control/controlbase+
go4.org/unsafe/assume-no-moving-gc from go4.org/intern
W 💣 golang.zx2c4.com/wintun from golang.zx2c4.com/wireguard/tun
💣 golang.zx2c4.com/wireguard/conn from golang.zx2c4.com/wireguard/device+
@@ -124,7 +129,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 golang.zx2c4.com/wireguard/tun from golang.zx2c4.com/wireguard/device+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/cmd/tailscaled+
gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/tcpip+
💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip/stack
gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/bufferv2
💣 gvisor.dev/gvisor/pkg/bufferv2 from gvisor.dev/gvisor/pkg/tcpip+
gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs+
💣 gvisor.dev/gvisor/pkg/gohacks from gvisor.dev/gvisor/pkg/state/wire+
gvisor.dev/gvisor/pkg/linewriter from gvisor.dev/gvisor/pkg/log
@@ -136,9 +142,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+
gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state
💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/linewriter+
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/header+
gvisor.dev/gvisor/pkg/tcpip/adapters/gonet from tailscale.com/wgengine/netstack
💣 gvisor.dev/gvisor/pkg/tcpip/buffer from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/tcpip/hash/jenkins from gvisor.dev/gvisor/pkg/tcpip/stack+
gvisor.dev/gvisor/pkg/tcpip/header from gvisor.dev/gvisor/pkg/tcpip/header/parse+
gvisor.dev/gvisor/pkg/tcpip/header/parse from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
@@ -147,48 +152,48 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
gvisor.dev/gvisor/pkg/tcpip/network/hash from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
gvisor.dev/gvisor/pkg/tcpip/network/internal/fragmentation from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
gvisor.dev/gvisor/pkg/tcpip/network/internal/ip from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
gvisor.dev/gvisor/pkg/tcpip/network/internal/multicast from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/net/tstun+
gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack
gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+
💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/header/parse+
gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/internal/network+
gvisor.dev/gvisor/pkg/tcpip/transport/icmp from tailscale.com/wgengine/netstack
gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/raw+
gvisor.dev/gvisor/pkg/tcpip/transport/internal/noop from gvisor.dev/gvisor/pkg/tcpip/transport/raw
gvisor.dev/gvisor/pkg/tcpip/transport/packet from gvisor.dev/gvisor/pkg/tcpip/transport/raw
gvisor.dev/gvisor/pkg/tcpip/transport/raw from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
gvisor.dev/gvisor/pkg/tcpip/transport/raw from gvisor.dev/gvisor/pkg/tcpip/transport/udp+
💣 gvisor.dev/gvisor/pkg/tcpip/transport/tcp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/tcpip/transport/tcpconntrack from gvisor.dev/gvisor/pkg/tcpip/stack
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/tcpip/transport/udp from tailscale.com/net/tstun+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
inet.af/netaddr from inet.af/wf+
inet.af/netaddr from tailscale.com/control/controlclient+
inet.af/peercred from tailscale.com/ipn/ipnserver
W 💣 inet.af/wf from tailscale.com/wf
L nhooyr.io/websocket from tailscale.com/derp/derphttp+
L nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
L nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
nhooyr.io/websocket from tailscale.com/derp/derphttp+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
tailscale.com from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/ipn+
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled
tailscale.com/client/tailscale from tailscale.com/derp
tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+
tailscale.com/control/controlbase from tailscale.com/control/controlclient+
tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+
tailscale.com/control/controlclient from tailscale.com/ipn/ipnlocal+
tailscale.com/control/controlhttp from tailscale.com/control/controlclient
tailscale.com/control/controlknobs from tailscale.com/control/controlclient+
tailscale.com/derp from tailscale.com/derp/derphttp+
tailscale.com/derp/derphttp from tailscale.com/cmd/tailscaled+
L tailscale.com/derp/wsconn from tailscale.com/derp/derphttp
tailscale.com/derp/derphttp from tailscale.com/net/netcheck+
tailscale.com/disco from tailscale.com/derp+
tailscale.com/envknob from tailscale.com/cmd/tailscaled+
tailscale.com/envknob from tailscale.com/control/controlclient+
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/hostinfo from tailscale.com/control/controlclient+
tailscale.com/ipn from tailscale.com/client/tailscale+
tailscale.com/ipn/ipnlocal from tailscale.com/ipn/ipnserver+
tailscale.com/ipn from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/ipnlocal from tailscale.com/ssh/tailssh+
tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
tailscale.com/ipn/ipnstate from tailscale.com/control/controlclient+
tailscale.com/ipn/localapi from tailscale.com/ipn/ipnserver
tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal
tailscale.com/ipn/store from tailscale.com/cmd/tailscaled
@@ -199,41 +204,41 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/log/filelogger from tailscale.com/logpolicy
tailscale.com/log/logheap from tailscale.com/control/controlclient
tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+
tailscale.com/logtail from tailscale.com/cmd/tailscaled+
tailscale.com/logtail/backoff from tailscale.com/cmd/tailscaled+
tailscale.com/logtail from tailscale.com/control/controlclient+
tailscale.com/logtail/backoff from tailscale.com/control/controlclient+
tailscale.com/logtail/filch from tailscale.com/logpolicy
💣 tailscale.com/metrics from tailscale.com/derp+
tailscale.com/net/dns from tailscale.com/cmd/tailscaled+
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dns/publicdns from tailscale.com/net/dns/resolver
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
tailscale.com/net/dns/resolver from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dnscache from tailscale.com/control/controlclient+
tailscale.com/net/dnsfallback from tailscale.com/control/controlclient+
tailscale.com/net/flowtrack from tailscale.com/net/packet+
💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscaled+
💣 tailscale.com/net/interfaces from tailscale.com/control/controlclient+
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
tailscale.com/net/netknob from tailscale.com/logpolicy+
tailscale.com/net/netns from tailscale.com/cmd/tailscaled+
tailscale.com/net/netknob from tailscale.com/net/netns+
tailscale.com/net/netns from tailscale.com/derp/derphttp+
💣 tailscale.com/net/netstat from tailscale.com/ipn/ipnserver
tailscale.com/net/netutil from tailscale.com/ipn/ipnlocal+
tailscale.com/net/packet from tailscale.com/net/tstun+
tailscale.com/net/portmapper from tailscale.com/cmd/tailscaled+
tailscale.com/net/portmapper from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
tailscale.com/net/stun from tailscale.com/net/netcheck+
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
tailscale.com/net/tsaddr from tailscale.com/ipn+
tailscale.com/net/tsdial from tailscale.com/cmd/tailscaled+
💣 tailscale.com/net/tshttpproxy from tailscale.com/cmd/tailscaled+
tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+
💣 tailscale.com/paths from tailscale.com/client/tailscale+
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
💣 tailscale.com/net/tshttpproxy from tailscale.com/control/controlclient+
tailscale.com/net/tstun from tailscale.com/net/dns+
tailscale.com/paths from tailscale.com/ipn/ipnlocal+
tailscale.com/portlist from tailscale.com/ipn/ipnlocal
tailscale.com/safesocket from tailscale.com/client/tailscale+
tailscale.com/smallzstd from tailscale.com/ipn/ipnserver+
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
tailscale.com/syncs from tailscale.com/control/controlknobs+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/wgengine/magicsock
@@ -244,8 +249,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/types/empty from tailscale.com/control/controlclient+
tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/key from tailscale.com/cmd/tailscaled+
tailscale.com/types/logger from tailscale.com/cmd/tailscaled+
tailscale.com/types/key from tailscale.com/control/controlbase+
tailscale.com/types/logger from tailscale.com/control/controlclient+
tailscale.com/types/netmap from tailscale.com/control/controlclient+
tailscale.com/types/nettype from tailscale.com/wgengine/magicsock
tailscale.com/types/opt from tailscale.com/control/controlclient+
@@ -254,31 +259,33 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/types/preftype from tailscale.com/ipn+
tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/views from tailscale.com/ipn/ipnlocal+
tailscale.com/util/clientmetric from tailscale.com/cmd/tailscaled+
tailscale.com/util/clientmetric from tailscale.com/control/controlclient+
tailscale.com/util/cloudenv from tailscale.com/net/dns/resolver+
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+
tailscale.com/util/dnsname from tailscale.com/hostinfo+
LW tailscale.com/util/endian from tailscale.com/net/dns+
tailscale.com/util/groupmember from tailscale.com/ipn/ipnserver
tailscale.com/util/lineread from tailscale.com/hostinfo+
tailscale.com/util/multierr from tailscale.com/cmd/tailscaled+
tailscale.com/util/mak from tailscale.com/control/controlclient+
tailscale.com/util/multierr from tailscale.com/control/controlclient+
tailscale.com/util/netconv from tailscale.com/wgengine/magicsock
tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+
tailscale.com/util/pidowner from tailscale.com/ipn/ipnserver
tailscale.com/util/racebuild from tailscale.com/logpolicy
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/systemd from tailscale.com/control/controlclient+
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock
tailscale.com/util/winutil from tailscale.com/cmd/tailscaled+
W 💣 tailscale.com/util/winutil/vss from tailscale.com/util/winutil
tailscale.com/version from tailscale.com/client/tailscale+
tailscale.com/version/distro from tailscale.com/cmd/tailscaled+
💣 tailscale.com/util/winutil from tailscale.com/cmd/tailscaled+
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/hostinfo+
W tailscale.com/wf from tailscale.com/cmd/tailscaled
tailscale.com/wgengine from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/monitor from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled
tailscale.com/wgengine/router from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/monitor from tailscale.com/control/controlclient+
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
tailscale.com/wgengine/wglog from tailscale.com/wgengine
@@ -298,7 +305,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/poly1305 from golang.zx2c4.com/wireguard/device+
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
LD golang.org/x/crypto/ssh from tailscale.com/ssh/tailssh
LD golang.org/x/crypto/ssh from tailscale.com/ssh/tailssh+
golang.org/x/net/bpf from github.com/mdlayher/genetlink+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from golang.org/x/net/http2+
@@ -312,12 +319,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from net+
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
golang.org/x/sync/singleflight from tailscale.com/control/controlclient+
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+
LD golang.org/x/sys/unix from github.com/insomniacslk/dhcp/interfaces+
W golang.org/x/sys/windows from github.com/go-ole/go-ole+
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
W golang.org/x/sys/windows/registry from golang.org/x/sys/windows/svc/eventlog+
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
W golang.org/x/sys/windows/svc/eventlog from tailscale.com/cmd/tailscaled
W golang.org/x/sys/windows/svc/mgr from tailscale.com/cmd/tailscaled
golang.org/x/term from tailscale.com/logpolicy
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
@@ -349,10 +356,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
crypto/sha256 from crypto/tls+
crypto/sha512 from crypto/ecdsa+
crypto/subtle from crypto/aes+
crypto/tls from github.com/aws/aws-sdk-go-v2/aws/transport/http+
crypto/tls from github.com/tcnksm/go-httpstat+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
embed from crypto/elliptic+
embed from tailscale.com+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base64 from encoding/json+
@@ -360,19 +367,19 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
encoding/xml from github.com/aws/aws-sdk-go-v2/aws/protocol/xml+
encoding/xml from github.com/tailscale/goupnp+
errors from bufio+
expvar from tailscale.com/derp+
flag from tailscale.com/cmd/tailscaled+
flag from tailscale.com/control/controlclient+
fmt from compress/flate+
hash from crypto+
hash/crc32 from compress/gzip+
hash/fnv from gvisor.dev/gvisor/pkg/tcpip/network/ipv6+
hash/fnv from tailscale.com/wgengine/magicsock+
hash/maphash from go4.org/mem
html from net/http/pprof+
html from tailscale.com/ipn/ipnlocal+
io from bufio+
io/fs from crypto/rand+
io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
io/ioutil from github.com/godbus/dbus/v5+
log from expvar+
LD log/syslog from tailscale.com/ssh/tailssh
math from compress/flate+
@@ -389,19 +396,19 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
net/http/internal from net/http+
net/http/pprof from tailscale.com/cmd/tailscaled+
net/netip from golang.zx2c4.com/wireguard/conn+
net/textproto from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
net/textproto from golang.org/x/net/http/httpguts+
net/url from crypto/x509+
os from crypto/rand+
os/exec from github.com/aws/aws-sdk-go-v2/credentials/processcreds+
os/exec from github.com/coreos/go-iptables/iptables+
os/signal from tailscale.com/cmd/tailscaled+
os/user from github.com/godbus/dbus/v5+
path from github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds+
path from github.com/godbus/dbus/v5+
path/filepath from crypto/x509+
reflect from crypto/x509+
regexp from github.com/aws/aws-sdk-go-v2/internal/endpoints/v2+
regexp from github.com/coreos/go-iptables/iptables+
regexp/syntax from regexp
runtime/debug from github.com/klauspost/compress/zstd+
runtime/pprof from net/http/pprof+
runtime/pprof from tailscale.com/log/logheap+
runtime/trace from net/http/pprof
sort from compress/flate+
strconv from compress/flate+

View File

@@ -9,4 +9,3 @@ package main
// Force registration of tailssh with LocalBackend.
import _ "tailscale.com/ssh/tailssh"

View File

@@ -137,7 +137,7 @@ func main() {
flag.StringVar(&args.httpProxyAddr, "outbound-http-proxy-listen", "", `optional [ip]:port to run an outbound HTTP proxy (e.g. "localhost:8080")`)
flag.StringVar(&args.tunname, "tun", defaultTunName(), `tunnel interface name; use "userspace-networking" (beta) to not use TUN`)
flag.Var(flagtype.PortValue(&args.port, 0), "port", "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
flag.StringVar(&args.statepath, "state", paths.DefaultTailscaledStateFile(), "absolute path of state file; use 'kube:<secret-name>' to use Kubernetes secrets or 'arn:aws:ssm:...' to store in AWS SSM; use 'mem:' to not store state and register as an emphemeral node. If empty and --statedir is provided, the default is <statedir>/tailscaled.state")
flag.StringVar(&args.statepath, "state", "", "absolute path of state file; use 'kube:<secret-name>' to use Kubernetes secrets or 'arn:aws:ssm:...' to store in AWS SSM; use 'mem:' to not store state and register as an emphemeral node. If empty and --statedir is provided, the default is <statedir>/tailscaled.state. Default: "+paths.DefaultTailscaledStateFile())
flag.StringVar(&args.statedir, "statedir", "", "path to directory for storage of config state, TLS certs, temporary incoming Taildrop files, etc. If empty, it's derived from --state when possible.")
flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket")
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
@@ -158,13 +158,12 @@ func main() {
}
}
if beWindowsSubprocess() {
return
}
flag.Parse()
if flag.NArg() > 0 {
log.Fatalf("tailscaled does not take non-flag arguments: %q", flag.Args())
// Windows subprocess is spawned with /subprocess, so we need to avoid this check there.
if runtime.GOOS != "windows" || (flag.Arg(0) != "/subproc" && flag.Arg(0) != "/firewall") {
log.Fatalf("tailscaled does not take non-flag arguments: %q", flag.Args())
}
}
if printVersion {
@@ -187,6 +186,16 @@ func main() {
log.Fatalf("--bird-socket is not supported on %s", runtime.GOOS)
}
// Only apply a default statepath when neither have been provided, so that a
// user may specify only --statedir if they wish.
if args.statepath == "" && args.statedir == "" {
args.statepath = paths.DefaultTailscaledStateFile()
}
if beWindowsSubprocess() {
return
}
err := run()
// Remove file sharing from Windows shell (noop in non-windows)
@@ -332,6 +341,7 @@ func run() error {
socksListener, httpProxyListener := mustStartProxyListeners(args.socksAddr, args.httpProxyAddr)
dialer := new(tsdial.Dialer) // mutated below (before used)
dialer.Logf = logf
e, useNetstack, err := createEngine(logf, linkMon, dialer)
if err != nil {
return fmt.Errorf("createEngine: %w", err)
@@ -341,7 +351,7 @@ func run() error {
}
if debugMux != nil {
if ig, ok := e.(wgengine.InternalsGetter); ok {
if _, mc, ok := ig.GetInternals(); ok {
if _, mc, _, ok := ig.GetInternals(); ok {
debugMux.HandleFunc("/debug/magicsock", mc.ServeHTTPDebug)
}
}
@@ -426,6 +436,7 @@ func run() error {
if err != nil {
return fmt.Errorf("safesocket.Listen: %v", err)
}
defer dialer.Close()
err = srv.Run(ctx, ln)
// Cancelation is not an error: it is the only way to stop ipnserver.
@@ -504,7 +515,7 @@ func tryEngine(logf logger.Logf, linkMon *monitor.Mon, dialer *tsdial.Dialer, na
} else {
dev, devName, err := tstun.New(logf, name)
if err != nil {
tstun.Diagnose(logf, name)
tstun.Diagnose(logf, name, err)
return nil, false, fmt.Errorf("tstun.New(%q): %w", name, err)
}
conf.Tun = dev
@@ -564,11 +575,11 @@ func runDebugServer(mux *http.ServeMux, addr string) {
}
func newNetstack(logf logger.Logf, dialer *tsdial.Dialer, e wgengine.Engine) (*netstack.Impl, error) {
tunDev, magicConn, ok := e.(wgengine.InternalsGetter).GetInternals()
tunDev, magicConn, dns, ok := e.(wgengine.InternalsGetter).GetInternals()
if !ok {
return nil, fmt.Errorf("%T is not a wgengine.InternalsGetter", e)
}
return netstack.Create(logf, tunDev, e, magicConn, dialer)
return netstack.Create(logf, tunDev, e, magicConn, dialer, dns)
}
// mustStartProxyListeners creates listeners for local SOCKS and HTTP

View File

@@ -30,6 +30,7 @@ import (
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/eventlog"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"inet.af/netaddr"
"tailscale.com/envknob"
@@ -60,12 +61,31 @@ func isWindowsService() bool {
return v
}
// syslogf is a logger function that writes to the Windows event log (ie, the
// one that you see in the Windows Event Viewer). tailscaled may optionally
// generate diagnostic messages in the same event timeline as the Windows
// Service Control Manager to assist with diagnosing issues with tailscaled's
// lifetime (such as slow shutdowns).
var syslogf logger.Logf = logger.Discard
// runWindowsService starts running Tailscale under the Windows
// Service environment.
//
// At this point we're still the parent process that
// Windows started.
func runWindowsService(pol *logpolicy.Policy) error {
if winutil.GetPolicyInteger("LogSCMInteractions", 0) != 0 {
syslog, err := eventlog.Open(serviceName)
if err == nil {
syslogf = func(format string, args ...any) {
syslog.Info(0, fmt.Sprintf(format, args...))
}
defer syslog.Close()
}
}
syslogf("Service entering svc.Run")
defer syslogf("Service exiting svc.Run")
return svc.Run(serviceName, &ipnService{Policy: pol})
}
@@ -75,7 +95,10 @@ type ipnService struct {
// Called by Windows to execute the windows service.
func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
defer syslogf("SvcStopped notification imminent")
changes <- svc.Status{State: svc.StartPending}
syslogf("Service start pending")
svcAccepts := svc.AcceptStop
if winutil.GetPolicyInteger("FlushDNSOnSessionUnlock", 0) != 0 {
@@ -98,26 +121,29 @@ func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, ch
}()
changes <- svc.Status{State: svc.Running, Accepts: svcAccepts}
syslogf("Service running")
for ctx.Err() == nil {
for {
select {
case <-doneCh:
return false, windows.NO_ERROR
case cmd := <-r:
log.Printf("Got Windows Service event: %v", cmdName(cmd.Cmd))
switch cmd.Cmd {
case svc.Stop:
cancel()
changes <- svc.Status{State: svc.StopPending}
syslogf("Service stop pending")
cancel() // so BabysitProc will kill the child process
case svc.Interrogate:
syslogf("Service interrogation")
changes <- cmd.CurrentStatus
case svc.SessionChange:
syslogf("Service session change notification")
handleSessionChange(cmd)
changes <- cmd.CurrentStatus
}
}
}
changes <- svc.Status{State: svc.StopPending}
return false, windows.NO_ERROR
}
func cmdName(c svc.Cmd) string {
@@ -234,19 +260,19 @@ func startIPNServer(ctx context.Context, logid string) error {
linkMon, err := monitor.New(logf)
if err != nil {
return err
return fmt.Errorf("monitor: %w", err)
}
dialer := new(tsdial.Dialer)
getEngineRaw := func() (wgengine.Engine, error) {
getEngineRaw := func() (wgengine.Engine, *netstack.Impl, error) {
dev, devName, err := tstun.New(logf, "Tailscale")
if err != nil {
return nil, fmt.Errorf("TUN: %w", err)
return nil, nil, fmt.Errorf("TUN: %w", err)
}
r, err := router.New(logf, dev, nil)
if err != nil {
dev.Close()
return nil, fmt.Errorf("router: %w", err)
return nil, nil, fmt.Errorf("router: %w", err)
}
if wrapNetstack {
r = netstack.NewSubnetRouterWrapper(r)
@@ -255,7 +281,7 @@ func startIPNServer(ctx context.Context, logid string) error {
if err != nil {
r.Close()
dev.Close()
return nil, fmt.Errorf("DNS: %w", err)
return nil, nil, fmt.Errorf("DNS: %w", err)
}
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Tun: dev,
@@ -268,23 +294,24 @@ func startIPNServer(ctx context.Context, logid string) error {
if err != nil {
r.Close()
dev.Close()
return nil, fmt.Errorf("engine: %w", err)
return nil, nil, fmt.Errorf("engine: %w", err)
}
ns, err := newNetstack(logf, dialer, eng)
if err != nil {
return nil, fmt.Errorf("newNetstack: %w", err)
return nil, nil, fmt.Errorf("newNetstack: %w", err)
}
ns.ProcessLocalIPs = false
ns.ProcessSubnets = wrapNetstack
if err := ns.Start(); err != nil {
return nil, fmt.Errorf("failed to start netstack: %w", err)
return nil, nil, fmt.Errorf("failed to start netstack: %w", err)
}
return wgengine.NewWatchdog(eng), nil
return wgengine.NewWatchdog(eng), ns, nil
}
type engineOrError struct {
Engine wgengine.Engine
Err error
Engine wgengine.Engine
Netstack *netstack.Impl
Err error
}
engErrc := make(chan engineOrError)
t0 := time.Now()
@@ -293,7 +320,7 @@ func startIPNServer(ctx context.Context, logid string) error {
for try := 1; ; try++ {
logf("tailscaled: getting engine... (try %v)", try)
t1 := time.Now()
eng, err := getEngineRaw()
eng, ns, err := getEngineRaw()
d, dt := time.Since(t1).Round(ms), time.Since(t1).Round(ms)
if err != nil {
logf("tailscaled: engine fetch error (try %v) in %v (total %v, sysUptime %v): %v",
@@ -306,7 +333,7 @@ func startIPNServer(ctx context.Context, logid string) error {
}
}
timer := time.NewTimer(5 * time.Second)
engErrc <- engineOrError{eng, err}
engErrc <- engineOrError{eng, ns, err}
if err == nil {
timer.Stop()
return
@@ -318,14 +345,14 @@ func startIPNServer(ctx context.Context, logid string) error {
// getEngine is called by ipnserver to get the engine. It's
// not called concurrently and is not called again once it
// successfully returns an engine.
getEngine := func() (wgengine.Engine, error) {
getEngine := func() (wgengine.Engine, *netstack.Impl, error) {
if msg := envknob.String("TS_DEBUG_WIN_FAIL"); msg != "" {
return nil, fmt.Errorf("pretending to be a service failure: %v", msg)
return nil, nil, fmt.Errorf("pretending to be a service failure: %v", msg)
}
for {
res := <-engErrc
if res.Engine != nil {
return res.Engine, nil
return res.Engine, res.Netstack, nil
}
if time.Since(t0) < time.Minute || windowsUptime() < 10*time.Minute {
// Ignore errors during early boot. Windows 10 auto logs in the GUI
@@ -336,12 +363,12 @@ func startIPNServer(ctx context.Context, logid string) error {
}
// Return nicer errors to users, annotated with logids, which helps
// when they file bugs.
return nil, fmt.Errorf("%w\n\nlogid: %v", res.Err, logid)
return nil, nil, fmt.Errorf("%w\n\nlogid: %v", res.Err, logid)
}
}
store, err := store.New(logf, statePathOrDefault())
if err != nil {
return err
return fmt.Errorf("store: %w", err)
}
ln, _, err := safesocket.Listen(args.socketpath, safesocket.WindowsLocalPort)

4
cmd/tsconnect/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
src/wasm_exec.js
src/main.wasm
node_modules/
dist/

190
cmd/tsconnect/build.go Normal file
View File

@@ -0,0 +1,190 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"io/fs"
"io/ioutil"
"log"
"os"
"path"
"path/filepath"
"github.com/andybalholm/brotli"
esbuild "github.com/evanw/esbuild/pkg/api"
"golang.org/x/sync/errgroup"
)
func runBuild() {
buildOptions, err := commonSetup(prodMode)
if err != nil {
log.Fatalf("Cannot setup: %v", err)
}
if err := cleanDist(); err != nil {
log.Fatalf("Cannot clean %s: %v", *distDir, err)
}
buildOptions.Write = true
buildOptions.MinifyWhitespace = true
buildOptions.MinifyIdentifiers = true
buildOptions.MinifySyntax = true
buildOptions.EntryNames = "[dir]/[name]-[hash]"
buildOptions.AssetNames = "[name]-[hash]"
buildOptions.Metafile = true
log.Printf("Running esbuild...\n")
result := esbuild.Build(*buildOptions)
if len(result.Errors) > 0 {
log.Printf("ESBuild Error:\n")
for _, e := range result.Errors {
log.Printf("%v", e)
}
log.Fatal("Build failed")
}
if len(result.Warnings) > 0 {
log.Printf("ESBuild Warnings:\n")
for _, w := range result.Warnings {
log.Printf("%v", w)
}
}
// Preserve build metadata so we can extract hashed file names for serving.
metadataBytes, err := fixEsbuildMetadataPaths(result.Metafile)
if err != nil {
log.Fatalf("Cannot fix esbuild metadata paths: %v", err)
}
if err := ioutil.WriteFile(path.Join(*distDir, "/esbuild-metadata.json"), metadataBytes, 0666); err != nil {
log.Fatalf("Cannot write metadata: %v", err)
}
if er := precompressDist(); err != nil {
log.Fatalf("Cannot precompress resources: %v", er)
}
}
// fixEsbuildMetadataPaths re-keys the esbuild metadata file to use paths
// relative to the dist directory (it normally uses paths relative to the cwd,
// which are akward if we're running with a different cwd at serving time).
func fixEsbuildMetadataPaths(metadataStr string) ([]byte, error) {
var metadata EsbuildMetadata
if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil {
return nil, fmt.Errorf("Cannot parse metadata: %w", err)
}
distAbsPath, err := filepath.Abs(*distDir)
if err != nil {
return nil, fmt.Errorf("Cannot get absolute path from %s: %w", *distDir, err)
}
for outputPath, output := range metadata.Outputs {
outputAbsPath, err := filepath.Abs(outputPath)
if err != nil {
return nil, fmt.Errorf("Cannot get absolute path from %s: %w", outputPath, err)
}
outputRelPath, err := filepath.Rel(distAbsPath, outputAbsPath)
if err != nil {
return nil, fmt.Errorf("Cannot get relative path from %s: %w", outputRelPath, err)
}
delete(metadata.Outputs, outputPath)
metadata.Outputs[outputRelPath] = output
}
return json.Marshal(metadata)
}
// cleanDist removes files from the dist build directory, except the placeholder
// one that we keep to make sure Git still creates the directory.
func cleanDist() error {
log.Printf("Cleaning %s...\n", *distDir)
files, err := os.ReadDir(*distDir)
if err != nil {
if os.IsNotExist(err) {
return os.MkdirAll(*distDir, 0755)
}
return err
}
for _, file := range files {
if file.Name() != "placeholder" {
if err := os.Remove(filepath.Join(*distDir, file.Name())); err != nil {
return err
}
}
}
return nil
}
func precompressDist() error {
log.Printf("Pre-compressing files in %s/...\n", *distDir)
var eg errgroup.Group
err := fs.WalkDir(os.DirFS(*distDir), ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if !compressibleExtensions[filepath.Ext(p)] {
return nil
}
p = path.Join(*distDir, p)
log.Printf("Pre-compressing %v\n", p)
eg.Go(func() error {
return precompress(p)
})
return nil
})
if err != nil {
return err
}
return eg.Wait()
}
var compressibleExtensions = map[string]bool{
".js": true,
".css": true,
".wasm": true,
}
func precompress(path string) error {
contents, err := os.ReadFile(path)
if err != nil {
return err
}
fi, err := os.Lstat(path)
if err != nil {
return err
}
err = writeCompressed(contents, func(w io.Writer) (io.WriteCloser, error) {
return gzip.NewWriterLevel(w, gzip.BestCompression)
}, path+".gz", fi.Mode())
if err != nil {
return err
}
return writeCompressed(contents, func(w io.Writer) (io.WriteCloser, error) {
return brotli.NewWriterLevel(w, brotli.BestCompression), nil
}, path+".br", fi.Mode())
}
func writeCompressed(contents []byte, compressedWriterCreator func(io.Writer) (io.WriteCloser, error), outputPath string, outputMode fs.FileMode) error {
var buf bytes.Buffer
compressedWriter, err := compressedWriterCreator(&buf)
if err != nil {
return err
}
if _, err := compressedWriter.Write(contents); err != nil {
return err
}
if err := compressedWriter.Close(); err != nil {
return err
}
return os.WriteFile(outputPath, buf.Bytes(), outputMode)
}

114
cmd/tsconnect/common.go Normal file
View File

@@ -0,0 +1,114 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"fmt"
"log"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strconv"
esbuild "github.com/evanw/esbuild/pkg/api"
)
const (
devMode = true
prodMode = false
)
// commonSetup performs setup that is common to both dev and build modes.
func commonSetup(dev bool) (*esbuild.BuildOptions, error) {
// Change cwd to to where this file lives -- that's where all inputs for
// esbuild and other build steps live.
if _, filename, _, ok := runtime.Caller(0); ok {
if err := os.Chdir(path.Dir(filename)); err != nil {
return nil, fmt.Errorf("Cannot change cwd: %w", err)
}
}
if err := buildDeps(dev); err != nil {
return nil, fmt.Errorf("Cannot build deps: %w", err)
}
return &esbuild.BuildOptions{
EntryPoints: []string{"src/index.js", "src/index.css"},
Loader: map[string]esbuild.Loader{".wasm": esbuild.LoaderFile},
Outdir: *distDir,
Bundle: true,
Sourcemap: esbuild.SourceMapLinked,
LogLevel: esbuild.LogLevelInfo,
Define: map[string]string{"DEBUG": strconv.FormatBool(dev)},
Target: esbuild.ES2017,
}, nil
}
// buildDeps builds the static assets that are needed for the server (except for
// JS/CSS bundling, which is handled by esbuild).
func buildDeps(dev bool) error {
if err := copyWasmExec(); err != nil {
return fmt.Errorf("Cannot copy wasm_exec.js: %w", err)
}
if err := buildWasm(dev); err != nil {
return fmt.Errorf("Cannot build main.wasm: %w", err)
}
if err := installJSDeps(); err != nil {
return fmt.Errorf("Cannot install JS deps: %w", err)
}
return nil
}
// copyWasmExec grabs the current wasm_exec.js runtime helper library from the
// Go toolchain.
func copyWasmExec() error {
log.Printf("Copying wasm_exec.js...\n")
wasmExecSrcPath := filepath.Join(runtime.GOROOT(), "misc", "wasm", "wasm_exec.js")
wasmExecDstPath := filepath.Join("src", "wasm_exec.js")
contents, err := os.ReadFile(wasmExecSrcPath)
if err != nil {
return err
}
return os.WriteFile(wasmExecDstPath, contents, 0600)
}
// buildWasm builds the Tailscale wasm binary and places it where the JS can
// load it.
func buildWasm(dev bool) error {
log.Printf("Building wasm...\n")
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"}
if !dev {
// Omit long paths and debug symbols in release builds, to reduce the
// generated WASM binary size.
args = append(args, "-trimpath", "-ldflags", "-s -w")
}
args = append(args, "-o", "src/main.wasm", "./wasm")
cmd := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...)
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// installJSDeps installs the JavaScript dependencies specified by package.json
func installJSDeps() error {
log.Printf("Installing JS deps...\n")
stdoutStderr, err := exec.Command("yarn").CombinedOutput()
if err != nil {
log.Printf("yarn failed: %s", stdoutStderr)
}
return err
}
// EsbuildMetadata is the subset of metadata struct (described by
// https://esbuild.github.io/api/#metafile) that we care about for mapping
// from entry points to hashed file names.
type EsbuildMetadata struct {
Outputs map[string]struct {
EntryPoint string `json:"entryPoint,omitempty"`
} `json:"outputs,omitempty"`
}

38
cmd/tsconnect/dev.go Normal file
View File

@@ -0,0 +1,38 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"log"
"net"
"strconv"
esbuild "github.com/evanw/esbuild/pkg/api"
)
func runDev() {
buildOptions, err := commonSetup(devMode)
if err != nil {
log.Fatalf("Cannot setup: %v", err)
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
log.Fatalf("Cannot parse addr: %v", err)
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
log.Fatalf("Cannot parse port: %v", err)
}
result, err := esbuild.Serve(esbuild.ServeOptions{
Port: uint16(port),
Host: host,
Servedir: "./",
}, *buildOptions)
if err != nil {
log.Fatalf("Cannot start esbuild server: %v", err)
}
log.Printf("Listening on http://%s:%d\n", result.Host, result.Port)
result.Wait()
}

2
cmd/tsconnect/dist/placeholder vendored Normal file
View File

@@ -0,0 +1,2 @@
This is here to make sure the dist/ directory exists for the go:embed command
in serve.go.

16
cmd/tsconnect/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="dist/index.css" />
</head>
<body>
<div id="header">
<h1>Tailscale Connect</h1>
<div id="state">Loading…</div>
</div>
<div id="peers"></div>
<script src="dist/index.js"></script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
{
"name": "@tailscale/ssh",
"version": "0.0.1",
"devDependencies": {
"qrcode": "^1.5.0",
"xterm": "^4.18.0"
},
"prettier": {
"semi": false,
"printWidth": 80
}
}

148
cmd/tsconnect/serve.go Normal file
View File

@@ -0,0 +1,148 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"io/ioutil"
"log"
"net/http"
"os"
"path"
"time"
"tailscale.com/tsweb"
)
//go:embed index.html
var embeddedFS embed.FS
//go:embed dist/*
var embeddedDistFS embed.FS
var serveStartTime = time.Now()
func runServe() {
mux := http.NewServeMux()
var distFS fs.FS
if *distDir == "./dist" {
var err error
distFS, err = fs.Sub(embeddedDistFS, "dist")
if err != nil {
log.Fatalf("Could not drop dist/ prefix from embedded FS: %v", err)
}
} else {
distFS = os.DirFS(*distDir)
}
indexBytes, err := generateServeIndex(distFS)
if err != nil {
log.Fatalf("Could not generate index.html: %v", err)
}
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "index.html", serveStartTime, bytes.NewReader(indexBytes))
}))
mux.Handle("/dist/", http.StripPrefix("/dist/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleServeDist(w, r, distFS)
})))
tsweb.Debugger(mux)
log.Printf("Listening on %s", *addr)
err = http.ListenAndServe(*addr, mux)
if err != nil {
log.Fatal(err)
}
}
func generateServeIndex(distFS fs.FS) ([]byte, error) {
log.Printf("Generating index.html...\n")
rawIndexBytes, err := embeddedFS.ReadFile("index.html")
if err != nil {
return nil, fmt.Errorf("Could not read index.html: %w", err)
}
esbuildMetadataFile, err := distFS.Open("esbuild-metadata.json")
if err != nil {
return nil, fmt.Errorf("Could not open esbuild-metadata.json: %w", err)
}
defer esbuildMetadataFile.Close()
esbuildMetadataBytes, err := ioutil.ReadAll(esbuildMetadataFile)
if err != nil {
return nil, fmt.Errorf("Could not read esbuild-metadata.json: %w", err)
}
var esbuildMetadata EsbuildMetadata
if err := json.Unmarshal(esbuildMetadataBytes, &esbuildMetadata); err != nil {
return nil, fmt.Errorf("Could not parse esbuild-metadata.json: %w", err)
}
entryPointsToHashedDistPaths := make(map[string]string)
for outputPath, output := range esbuildMetadata.Outputs {
if output.EntryPoint != "" {
entryPointsToHashedDistPaths[output.EntryPoint] = path.Join("dist", outputPath)
}
}
indexBytes := rawIndexBytes
for entryPointPath, defaultDistPath := range entryPointsToDefaultDistPaths {
hashedDistPath := entryPointsToHashedDistPaths[entryPointPath]
if hashedDistPath != "" {
indexBytes = bytes.ReplaceAll(indexBytes, []byte(defaultDistPath), []byte(hashedDistPath))
}
}
return indexBytes, nil
}
var entryPointsToDefaultDistPaths = map[string]string{
"src/index.css": "dist/index.css",
"src/index.js": "dist/index.js",
}
func handleServeDist(w http.ResponseWriter, r *http.Request, distFS fs.FS) {
path := r.URL.Path
var f fs.File
// Prefer pre-compressed versions generated during the build step.
if tsweb.AcceptsEncoding(r, "br") {
if brotliFile, err := distFS.Open(path + ".br"); err == nil {
f = brotliFile
w.Header().Set("Content-Encoding", "br")
}
}
if f == nil && tsweb.AcceptsEncoding(r, "gzip") {
if gzipFile, err := distFS.Open(path + ".gz"); err == nil {
f = gzipFile
w.Header().Set("Content-Encoding", "gzip")
}
}
if f == nil {
if rawFile, err := distFS.Open(path); err == nil {
f = rawFile
} else {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
}
defer f.Close()
// fs.File does not claim to implement Seeker, but in practice it does.
fSeeker, ok := f.(io.ReadSeeker)
if !ok {
http.Error(w, "Not seekable", http.StatusInternalServerError)
return
}
// Aggressively cache static assets, since we cache-bust our assets with
// hashed filenames.
w.Header().Set("Cache-Control", "public, max-age=31535996")
w.Header().Set("Vary", "Accept-Encoding")
http.ServeContent(w, r, path, serveStartTime, fSeeker)
}

View File

@@ -0,0 +1,91 @@
/* Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. */
/* Use of this source code is governed by a BSD-style */
/* license that can be found in the LICENSE file. */
@import "xterm/css/xterm.css";
html {
background: #fff;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
body {
margin: 0;
}
button {
font-family: inherit;
border: solid 1px #ccc;
background: #fff;
color: #000;
padding: 4px 8px;
border-radius: 4px;
}
#header {
background: #f7f5f4;
border-bottom: 1px solid #eeebea;
padding: 12px;
display: flex;
align-items: center;
}
#header h1 {
margin: 0;
flex-grow: 1;
}
#header #state {
padding: 0 8px;
color: #444342;
}
#peers {
box-sizing: border-box;
width: 100%;
padding: 12px;
}
.login {
text-align: center;
}
.logout {
font-weight: bold;
}
.peer {
display: flex;
justify-content: space-between;
padding: 2px;
}
.peer:hover {
background: #eee;
}
.peer .name {
font-family: monospace;
}
.peer .ssh {
background-color: #cbf4c9;
}
.term-container {
padding: 12px;
}
.xterm-viewport.xterm-viewport {
scrollbar-width: thin;
}
.xterm-viewport::-webkit-scrollbar {
width: 10px;
}
.xterm-viewport::-webkit-scrollbar-track {
opacity: 0;
}
.xterm-viewport::-webkit-scrollbar-thumb {
min-height: 20px;
background-color: #ffffff20;
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
import "./wasm_exec"
import wasmUrl from "./main.wasm"
import { notifyState, notifyNetMap, notifyBrowseToURL } from "./notifier"
import { sessionStateStorage } from "./js-state-store"
const go = new window.Go()
WebAssembly.instantiateStreaming(
fetch(`./dist/${wasmUrl}`),
go.importObject
).then((result) => {
go.run(result.instance)
const ipn = newIPN({
// Persist IPN state in sessionStorage in development, so that we don't need
// to re-authorize every time we reload the page.
stateStorage: DEBUG ? sessionStateStorage : undefined,
})
ipn.run({
notifyState: notifyState.bind(null, ipn),
notifyNetMap: notifyNetMap.bind(null, ipn),
notifyBrowseToURL: notifyBrowseToURL.bind(null, ipn),
})
})

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
/**
* @fileoverview Callbacks used by jsStateStore to persist IPN state.
*/
export const sessionStateStorage = {
setState(id, value) {
window.sessionStorage[`ipn-state-${id}`] = value
},
getState(id) {
return window.sessionStorage[`ipn-state-${id}`] || ""
},
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
import QRCode from "qrcode"
export async function showLoginURL(url) {
if (loginNode) {
loginNode.remove()
}
loginNode = document.createElement("div")
loginNode.className = "login"
const linkNode = document.createElement("a")
linkNode.href = url
linkNode.target = "_blank"
loginNode.appendChild(linkNode)
try {
const dataURL = await QRCode.toDataURL(url, { width: 512 })
const imageNode = document.createElement("img")
imageNode.src = dataURL
imageNode.width = 256
imageNode.height = 256
imageNode.border = "0"
linkNode.appendChild(imageNode)
} catch (err) {
console.error("Could not generate QR code:", err)
}
linkNode.appendChild(document.createElement("br"))
linkNode.appendChild(document.createTextNode(url))
document.body.appendChild(loginNode)
}
export function hideLoginURL() {
if (!loginNode) {
return
}
loginNode.remove()
loginNode = undefined
}
let loginNode
export function showLogoutButton(ipn) {
if (logoutButtonNode) {
logoutButtonNode.remove()
}
logoutButtonNode = document.createElement("button")
logoutButtonNode.className = "logout"
logoutButtonNode.textContent = "Logout"
logoutButtonNode.addEventListener(
"click",
() => {
ipn.logout()
},
{ once: true }
)
document.getElementById("header").appendChild(logoutButtonNode)
}
export function hideLogoutButton() {
if (!logoutButtonNode) {
return
}
logoutButtonNode.remove()
logoutButtonNode = undefined
}
let logoutButtonNode

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
import {
showLoginURL,
hideLoginURL,
showLogoutButton,
hideLogoutButton,
} from "./login"
import { showSSHPeers, hideSSHPeers } from "./ssh"
/**
* @fileoverview Notification callback functions (bridged from ipn.Notify)
*/
/** Mirrors values from ipn/backend.go */
const State = {
NoState: 0,
InUseOtherUser: 1,
NeedsLogin: 2,
NeedsMachineAuth: 3,
Stopped: 4,
Starting: 5,
Running: 6,
}
export function notifyState(ipn, state) {
let stateLabel
switch (state) {
case State.NoState:
stateLabel = "Initializing…"
break
case State.InUseOtherUser:
stateLabel = "In-use by another user"
break
case State.NeedsLogin:
stateLabel = "Needs Login"
hideLogoutButton()
hideSSHPeers()
ipn.login()
break
case State.NeedsMachineAuth:
stateLabel = "Needs authorization"
break
case State.Stopped:
stateLabel = "Stopped"
hideLogoutButton()
hideSSHPeers()
break
case State.Starting:
stateLabel = "Starting…"
break
case State.Running:
stateLabel = "Running"
hideLoginURL()
showLogoutButton(ipn)
break
}
const stateNode = document.getElementById("state")
stateNode.textContent = stateLabel ?? ""
}
export function notifyNetMap(ipn, netMapStr) {
const netMap = JSON.parse(netMapStr)
if (DEBUG) {
console.log("Received net map: " + JSON.stringify(netMap, null, 2))
}
showSSHPeers(netMap.peers, ipn)
}
export function notifyBrowseToURL(ipn, url) {
showLoginURL(url)
}

77
cmd/tsconnect/src/ssh.js Normal file
View File

@@ -0,0 +1,77 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
import { Terminal } from "xterm"
export function showSSHPeers(peers, ipn) {
const peersNode = document.getElementById("peers")
peersNode.innerHTML = ""
const sshPeers = peers.filter((p) => p.tailscaleSSHEnabled)
if (!sshPeers.length) {
peersNode.textContent = "No machines have Tailscale SSH installed."
return
}
for (const peer of sshPeers) {
const peerNode = document.createElement("div")
peerNode.className = "peer"
const nameNode = document.createElement("div")
nameNode.className = "name"
nameNode.textContent = peer.name
peerNode.appendChild(nameNode)
const sshButtonNode = document.createElement("button")
sshButtonNode.className = "ssh"
sshButtonNode.addEventListener("click", function () {
ssh(peer.name, ipn)
})
sshButtonNode.textContent = "SSH"
peerNode.appendChild(sshButtonNode)
peersNode.appendChild(peerNode)
}
}
export function hideSSHPeers() {
const peersNode = document.getElementById("peers")
peersNode.innerHTML = ""
}
function ssh(hostname, ipn) {
const termContainerNode = document.createElement("div")
termContainerNode.className = "term-container"
document.body.appendChild(termContainerNode)
const term = new Terminal({
cursorBlink: true,
})
term.open(termContainerNode)
// Cancel wheel events from scrolling the page if the terminal has scrollback
termContainerNode.addEventListener("wheel", (e) => {
if (term.buffer.active.baseY > 0) {
e.preventDefault()
}
})
let onDataHook
term.onData((e) => {
onDataHook?.(e)
})
term.focus()
ipn.ssh(
hostname,
(input) => term.write(input),
(hook) => (onDataHook = hook),
term.rows,
term.cols,
() => {
term.dispose()
termContainerNode.remove()
}
)
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// The tsconnect command builds and serves the static site that is generated for
// the Tailscale Connect JS/WASM client. Can be run in 3 modes:
// - dev: builds the site and serves it. JS and CSS changes can be picked up
// with a reload.
// - build: builds the site and writes it to dist/
// - serve: serves the site from dist/ (embedded in the binary)
package main // import "tailscale.com/cmd/tsconnect"
import (
"flag"
"fmt"
"log"
"os"
)
var (
addr = flag.String("addr", ":9090", "address to listen on")
distDir = flag.String("distdir", "./dist", "path of directory to place build output in")
)
func main() {
flag.Usage = usage
flag.Parse()
if len(flag.Args()) != 1 {
flag.Usage()
}
switch flag.Arg(0) {
case "dev":
runDev()
case "build":
runBuild()
case "serve":
runServe()
default:
log.Printf("Unknown command: %s", flag.Arg(0))
flag.Usage()
}
}
func usage() {
fmt.Fprintf(os.Stderr, `
usage: tsconnect {dev|build|serve}
`[1:])
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, `
tsconnect implements development/build/serving workflows for Tailscale Connect.
It can be invoked with one of three subcommands:
- dev: Run in development mode, allowing JS and CSS changes to be picked up without a rebuilt or restart.
- build: Run in production build mode (generating static assets)
- serve: Run in production serve mode (serving static assets)
`[1:])
os.Exit(2)
}

View File

@@ -0,0 +1,411 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// The wasm package builds a WebAssembly module that provides a subset of
// Tailscale APIs to JavaScript.
//
// When run in the browser, a newIPN(config) function is added to the global JS
// namespace. When called it returns an ipn object with the methods
// run(callbacks), login(), logout(), and ssh(...).
package main
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"math/rand"
"net"
"strings"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
"inet.af/netaddr"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
"tailscale.com/words"
)
func main() {
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) != 1 {
log.Fatal("Usage: newIPN(config)")
return nil
}
return newIPN(args[0])
}))
// Keep Go runtime alive, otherwise it will be shut down before newIPN gets
// called.
<-make(chan bool)
}
func newIPN(jsConfig js.Value) map[string]any {
netns.SetEnabled(false)
var logf logger.Logf = log.Printf
dialer := new(tsdial.Dialer)
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer,
})
if err != nil {
log.Fatal(err)
}
tunDev, magicConn, dnsManager, ok := eng.(wgengine.InternalsGetter).GetInternals()
if !ok {
log.Fatalf("%T is not a wgengine.InternalsGetter", eng)
}
ns, err := netstack.Create(logf, tunDev, eng, magicConn, dialer, dnsManager)
if err != nil {
log.Fatalf("netstack.Create: %v", err)
}
ns.ProcessLocalIPs = true
ns.ProcessSubnets = true
if err := ns.Start(); err != nil {
log.Fatalf("failed to start netstack: %v", err)
}
dialer.UseNetstackForIP = func(ip netaddr.IP) bool {
return true
}
dialer.NetstackDialTCP = func(ctx context.Context, dst netaddr.IPPort) (net.Conn, error) {
return ns.DialContextTCP(ctx, dst)
}
jsStateStorage := jsConfig.Get("stateStorage")
var store ipn.StateStore
if jsStateStorage.IsUndefined() {
store = new(mem.Store)
} else {
store = &jsStateStore{jsStateStorage}
}
srv, err := ipnserver.New(log.Printf, "some-logid", store, eng, dialer, nil, ipnserver.Options{
SurviveDisconnects: true,
LoginFlags: controlclient.LoginEphemeral,
})
if err != nil {
log.Fatalf("ipnserver.New: %v", err)
}
lb := srv.LocalBackend()
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
lb: lb,
}
return map[string]any{
"run": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) != 1 {
log.Fatal(`Usage: run({
notifyState(state: int): void,
notifyNetMap(netMap: object): void,
notifyBrowseToURL(url: string): void,
})`)
return nil
}
jsIPN.run(args[0])
return nil
}),
"login": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) != 0 {
log.Printf("Usage: login()")
return nil
}
jsIPN.login()
return nil
}),
"logout": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) != 0 {
log.Printf("Usage: logout()")
return nil
}
jsIPN.logout()
return nil
}),
"ssh": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) != 6 {
log.Printf("Usage: ssh(hostname, writeFn, readFn, rows, cols, onDone)")
return nil
}
go jsIPN.ssh(
args[0].String(),
args[1],
args[2],
args[3].Int(),
args[4].Int(),
args[5])
return nil
}),
}
}
type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
}
func (i *jsIPN) run(jsCallbacks js.Value) {
notifyState := func(state ipn.State) {
jsCallbacks.Call("notifyState", int(state))
}
notifyState(ipn.NoState)
i.lb.SetNotifyCallback(func(n ipn.Notify) {
log.Printf("NOTIFY: %+v", n)
if n.State != nil {
notifyState(*n.State)
}
if nm := n.NetMap; nm != nil {
jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.Name,
Addresses: mapSlice(nm.Addresses, func(a netaddr.IPPrefix) string { return a.IP().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
},
MachineStatus: int(nm.MachineStatus),
},
Peers: mapSlice(nm.Peers, func(p *tailcfg.Node) jsNetMapPeerNode {
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: p.Name,
Addresses: mapSlice(p.Addresses, func(a netaddr.IPPrefix) string { return a.IP().String() }),
MachineKey: p.Machine.String(),
NodeKey: p.Key.String(),
},
Online: *p.Online,
TailscaleSSHEnabled: p.Hostinfo.TailscaleSSHEnabled(),
}
}),
}
if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
} else {
log.Printf("Could not generate JSON netmap: %v", err)
}
}
if n.BrowseToURL != nil {
jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
}
})
go func() {
err := i.lb.Start(ipn.Options{
StateKey: "wasm",
UpdatePrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
RouteAll: false,
AllowSingleHosts: true,
WantRunning: true,
Hostname: generateHostname(),
},
})
if err != nil {
log.Printf("Start error: %v", err)
}
}()
go func() {
ln, _, err := safesocket.Listen("", 0)
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
err = i.srv.Run(context.Background(), ln)
log.Fatalf("ipnserver.Run exited: %v", err)
}()
}
func (i *jsIPN) login() {
go i.lb.StartLoginInteractive()
}
func (i *jsIPN) logout() {
if i.lb.State() == ipn.NoState {
log.Printf("Backend not running")
}
go i.lb.Logout()
}
func (i *jsIPN) ssh(host string, writeFn js.Value, setReadFn js.Value, rows, cols int, onDone js.Value) {
defer onDone.Invoke()
write := func(s string) {
writeFn.Invoke(s)
}
writeError := func(label string, err error) {
write(fmt.Sprintf("%s Error: %v\r\n", label, err))
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, err := i.dialer.UserDial(ctx, "tcp", net.JoinHostPort(host, "22"))
if err != nil {
writeError("Dial", err)
return
}
defer c.Close()
config := &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
sshConn, _, _, err := ssh.NewClientConn(c, host, config)
if err != nil {
writeError("SSH Connection", err)
return
}
defer sshConn.Close()
write("SSH Connected\r\n")
sshClient := ssh.NewClient(sshConn, nil, nil)
defer sshClient.Close()
session, err := sshClient.NewSession()
if err != nil {
writeError("SSH Session", err)
return
}
write("Session Established\r\n")
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
writeError("SSH Stdin", err)
return
}
session.Stdout = termWriter{writeFn}
session.Stderr = termWriter{writeFn}
setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) interface{} {
input := args[0].String()
_, err := stdin.Write([]byte(input))
if err != nil {
writeError("Write Input", err)
}
return nil
}))
err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
if err != nil {
writeError("Pseudo Terminal", err)
return
}
err = session.Shell()
if err != nil {
writeError("Shell", err)
return
}
err = session.Wait()
if err != nil {
writeError("Exit", err)
return
}
}
type termWriter struct {
f js.Value
}
func (w termWriter) Write(p []byte) (n int, err error) {
r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1)
w.f.Invoke(string(r))
return len(p), nil
}
type jsNetMap struct {
Self jsNetMapSelfNode `json:"self"`
Peers []jsNetMapPeerNode `json:"peers"`
}
type jsNetMapNode struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
MachineStatus int `json:"machineStatus"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
}
type jsNetMapSelfNode struct {
jsNetMapNode
MachineStatus int `json:"machineStatus"`
}
type jsNetMapPeerNode struct {
jsNetMapNode
Online bool `json:"online"`
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
}
type jsStateStore struct {
jsStateStorage js.Value
}
func (s *jsStateStore) ReadState(id ipn.StateKey) ([]byte, error) {
jsValue := s.jsStateStorage.Call("getState", string(id))
if jsValue.String() == "" {
return nil, ipn.ErrStateNotExist
}
return hex.DecodeString(jsValue.String())
}
func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error {
s.jsStateStorage.Call("setState", string(id), hex.EncodeToString(bs))
return nil
}
func mapSlice[T any, M any](a []T, f func(T) M) []M {
n := make([]M, len(a))
for i, e := range a {
n[i] = f(e)
}
return n
}
func filterSlice[T any](a []T, f func(T) bool) []T {
n := make([]T, 0, len(a))
for _, e := range a {
if f(e) {
n = append(n, e)
}
}
return n
}
func generateHostname() string {
tails := words.Tails()
scales := words.Scales()
if rand.Int()%2 == 0 {
// JavaScript
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "j") })
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "s") })
} else {
// WebAssembly
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "w") })
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "a") })
}
tail := tails[rand.Intn(len(tails))]
scale := scales[rand.Intn(len(scales))]
return fmt.Sprintf("%s-%s", tail, scale)
}

205
cmd/tsconnect/yarn.lock Normal file
View File

@@ -0,0 +1,205 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
ansi-regex@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
ansi-styles@^4.0.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
dependencies:
color-convert "^2.0.1"
camelcase@^5.0.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
cliui@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.0"
wrap-ansi "^6.2.0"
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
dependencies:
color-name "~1.1.4"
color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
dijkstrajs@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257"
integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
encode-utf8@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==
find-up@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
dependencies:
locate-path "^5.0.0"
path-exists "^4.0.0"
get-caller-file@^2.0.1:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
is-fullwidth-code-point@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
locate-path@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
dependencies:
p-locate "^4.1.0"
p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
dependencies:
p-try "^2.0.0"
p-locate@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
dependencies:
p-limit "^2.2.0"
p-try@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
pngjs@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
qrcode@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b"
integrity sha512-9MgRpgVc+/+47dFvQeD6U2s0Z92EsKzcHogtum4QB+UNd025WOJSHvn/hjk9xmzj7Stj95CyUAs31mrjxliEsQ==
dependencies:
dijkstrajs "^1.0.1"
encode-utf8 "^1.0.3"
pngjs "^5.0.0"
yargs "^15.3.1"
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
require-main-filename@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
string-width@^4.1.0, string-width@^4.2.0:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
xterm@^4.18.0:
version "4.18.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"
integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ==
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
yargs-parser@^18.1.2:
version "18.1.3"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
dependencies:
camelcase "^5.0.0"
decamelize "^1.2.0"
yargs@^15.3.1:
version "15.4.1"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
dependencies:
cliui "^6.0.0"
decamelize "^1.2.0"
find-up "^4.1.0"
get-caller-file "^2.0.1"
require-directory "^2.1.1"
require-main-filename "^2.0.0"
set-blocking "^2.0.0"
string-width "^4.2.0"
which-module "^2.0.0"
y18n "^4.0.0"
yargs-parser "^18.1.2"

View File

@@ -2,177 +2,12 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !windows
// +build !windows
//go:build ignore
// +build ignore
// The tsshd binary is an SSH server that accepts connections
// The tsshd binary was an experimental SSH server that accepts connections
// from anybody on the same Tailscale network.
//
// It does not use passwords or SSH public key.
// Its functionality moved into tailscaled.
//
// Any user name is accepted; users are logged in as whoever is
// running this daemon.
//
// Warning: use at your own risk. This code has had very few eyeballs
// on it.
package main
import (
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"os"
"os/exec"
"syscall"
"time"
"unsafe"
"github.com/creack/pty"
gossh "github.com/tailscale/golang-x-crypto/ssh"
"inet.af/netaddr"
"tailscale.com/net/interfaces"
"tailscale.com/net/tsaddr"
"tailscale.com/tempfork/gliderlabs/ssh"
)
var (
port = flag.Int("port", 2200, "port to listen on")
hostKey = flag.String("hostkey", "", "SSH host key")
)
func main() {
flag.Parse()
if *hostKey == "" {
log.Fatalf("missing required --hostkey")
}
hostKey, err := ioutil.ReadFile(*hostKey)
if err != nil {
log.Fatal(err)
}
signer, err := gossh.ParsePrivateKey(hostKey)
if err != nil {
log.Printf("failed to parse SSH host key: %v", err)
return
}
warned := false
for {
addrs, iface, err := interfaces.Tailscale()
if err != nil {
log.Fatalf("listing interfaces: %v", err)
}
if len(addrs) == 0 {
if !warned {
log.Printf("no tailscale interface found; polling until one is available")
warned = true
}
// TODO: use netlink or other OS-specific mechanism to efficiently
// wait for change in interfaces. Polling every N seconds is good enough
// for now.
time.Sleep(5 * time.Second)
continue
}
warned = false
var addr netaddr.IP
for _, a := range addrs {
if a.Is4() {
addr = a
break
}
}
listen := net.JoinHostPort(addr.String(), fmt.Sprint(*port))
log.Printf("tailscale ssh server listening on %v, %v", iface.Name, listen)
s := &ssh.Server{
Addr: listen,
Handler: handleSSH,
}
s.AddHostKey(signer)
err = s.ListenAndServe()
log.Fatalf("tailscale sshd failed: %v", err)
}
}
func handleSSH(s ssh.Session) {
user := s.User()
addr := s.RemoteAddr()
ta, ok := addr.(*net.TCPAddr)
if !ok {
log.Printf("tsshd: rejecting non-TCP addr %T %v", addr, addr)
s.Exit(1)
return
}
tanetaddr, ok := netaddr.FromStdIP(ta.IP)
if !ok {
log.Printf("tsshd: rejecting unparseable addr %v", ta.IP)
s.Exit(1)
return
}
if !tsaddr.IsTailscaleIP(tanetaddr) {
log.Printf("tsshd: rejecting non-Tailscale addr %v", ta.IP)
s.Exit(1)
return
}
log.Printf("new session for %q from %v", user, ta)
defer log.Printf("closing session for %q from %v", user, ta)
ptyReq, winCh, isPty := s.Pty()
if !isPty {
fmt.Fprintf(s, "TODO scp etc")
s.Exit(1)
return
}
userWantsShell := len(s.Command()) == 0
if userWantsShell {
shell, err := shellOfUser(s.User())
if err != nil {
fmt.Fprintf(s, "failed to find shell: %v\n", err)
s.Exit(1)
return
}
cmd := exec.Command(shell)
cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))
f, err := pty.Start(cmd)
if err != nil {
log.Printf("running shell: %v", err)
s.Exit(1)
return
}
defer f.Close()
go func() {
for win := range winCh {
setWinsize(f, win.Width, win.Height)
}
}()
go func() {
io.Copy(f, s) // stdin
}()
io.Copy(s, f) // stdout
cmd.Process.Kill()
if err := cmd.Wait(); err != nil {
s.Exit(1)
} else {
s.Exit(0)
}
return
}
fmt.Fprintf(s, "TODO: args\n")
s.Exit(1)
}
func shellOfUser(user string) (string, error) {
// TODO
return "/bin/bash", nil
}
func setWinsize(f *os.File, w, h int) {
syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ),
uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0})))
}
// See https://github.com/tailscale/tailscale/issues/3802

View File

@@ -1,12 +0,0 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build windows
// +build windows
package main
func main() {
panic("tsshd does not work on windows yet")
}

59
cmd/viewer/tests/tests.go Normal file
View File

@@ -0,0 +1,59 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package tests serves a list of tests for tailscale.com/cmd/viewer.
package tests
import (
"fmt"
"inet.af/netaddr"
)
//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices
type StructWithoutPtrs struct {
Int int
Pfx netaddr.IPPrefix
}
type Map struct {
Int map[string]int
SliceInt map[string][]int
StructWithPtr map[string]*StructWithPtrs
StructWithoutPtr map[string]*StructWithoutPtrs
SlicesWithPtrs map[string][]*StructWithPtrs
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
StructWithoutPtrKey map[StructWithoutPtrs]int `json:"-"`
// Unsupported.
SliceIntPtr map[string][]*int
PointerKey map[*string]int `json:"-"`
StructWithPtrKey map[StructWithPtrs]int `json:"-"`
}
type StructWithPtrs struct {
Value *StructWithoutPtrs
Int *int
NoCloneValue *StructWithoutPtrs `codegen:"noclone"`
}
func (v *StructWithPtrs) String() string { return fmt.Sprintf("%v", v.Int) }
func (v *StructWithPtrs) Equal(v2 *StructWithPtrs) bool {
return v.Value == v2.Value
}
type StructWithSlices struct {
Values []StructWithoutPtrs
ValuePointers []*StructWithoutPtrs
StructPointers []*StructWithPtrs
Structs []StructWithPtrs
Ints []*int
Slice []string
Prefixes []netaddr.IPPrefix
Data []byte
}

View File

@@ -0,0 +1,183 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
package tests
import (
"inet.af/netaddr"
)
// Clone makes a deep copy of StructWithPtrs.
// The result aliases no memory with the original.
func (src *StructWithPtrs) Clone() *StructWithPtrs {
if src == nil {
return nil
}
dst := new(StructWithPtrs)
*dst = *src
if dst.Value != nil {
dst.Value = new(StructWithoutPtrs)
*dst.Value = *src.Value
}
if dst.Int != nil {
dst.Int = new(int)
*dst.Int = *src.Int
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithPtrsCloneNeedsRegeneration = StructWithPtrs(struct {
Value *StructWithoutPtrs
Int *int
NoCloneValue *StructWithoutPtrs
}{})
// Clone makes a deep copy of StructWithoutPtrs.
// The result aliases no memory with the original.
func (src *StructWithoutPtrs) Clone() *StructWithoutPtrs {
if src == nil {
return nil
}
dst := new(StructWithoutPtrs)
*dst = *src
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithoutPtrsCloneNeedsRegeneration = StructWithoutPtrs(struct {
Int int
Pfx netaddr.IPPrefix
}{})
// Clone makes a deep copy of Map.
// The result aliases no memory with the original.
func (src *Map) Clone() *Map {
if src == nil {
return nil
}
dst := new(Map)
*dst = *src
if dst.Int != nil {
dst.Int = map[string]int{}
for k, v := range src.Int {
dst.Int[k] = v
}
}
if dst.SliceInt != nil {
dst.SliceInt = map[string][]int{}
for k := range src.SliceInt {
dst.SliceInt[k] = append([]int{}, src.SliceInt[k]...)
}
}
if dst.StructWithPtr != nil {
dst.StructWithPtr = map[string]*StructWithPtrs{}
for k, v := range src.StructWithPtr {
dst.StructWithPtr[k] = v.Clone()
}
}
if dst.StructWithoutPtr != nil {
dst.StructWithoutPtr = map[string]*StructWithoutPtrs{}
for k, v := range src.StructWithoutPtr {
dst.StructWithoutPtr[k] = v.Clone()
}
}
if dst.SlicesWithPtrs != nil {
dst.SlicesWithPtrs = map[string][]*StructWithPtrs{}
for k := range src.SlicesWithPtrs {
dst.SlicesWithPtrs[k] = append([]*StructWithPtrs{}, src.SlicesWithPtrs[k]...)
}
}
if dst.SlicesWithoutPtrs != nil {
dst.SlicesWithoutPtrs = map[string][]*StructWithoutPtrs{}
for k := range src.SlicesWithoutPtrs {
dst.SlicesWithoutPtrs[k] = append([]*StructWithoutPtrs{}, src.SlicesWithoutPtrs[k]...)
}
}
if dst.StructWithoutPtrKey != nil {
dst.StructWithoutPtrKey = map[StructWithoutPtrs]int{}
for k, v := range src.StructWithoutPtrKey {
dst.StructWithoutPtrKey[k] = v
}
}
if dst.SliceIntPtr != nil {
dst.SliceIntPtr = map[string][]*int{}
for k := range src.SliceIntPtr {
dst.SliceIntPtr[k] = append([]*int{}, src.SliceIntPtr[k]...)
}
}
if dst.PointerKey != nil {
dst.PointerKey = map[*string]int{}
for k, v := range src.PointerKey {
dst.PointerKey[k] = v
}
}
if dst.StructWithPtrKey != nil {
dst.StructWithPtrKey = map[StructWithPtrs]int{}
for k, v := range src.StructWithPtrKey {
dst.StructWithPtrKey[k] = v
}
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _MapCloneNeedsRegeneration = Map(struct {
Int map[string]int
SliceInt map[string][]int
StructWithPtr map[string]*StructWithPtrs
StructWithoutPtr map[string]*StructWithoutPtrs
SlicesWithPtrs map[string][]*StructWithPtrs
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
StructWithoutPtrKey map[StructWithoutPtrs]int
SliceIntPtr map[string][]*int
PointerKey map[*string]int
StructWithPtrKey map[StructWithPtrs]int
}{})
// Clone makes a deep copy of StructWithSlices.
// The result aliases no memory with the original.
func (src *StructWithSlices) Clone() *StructWithSlices {
if src == nil {
return nil
}
dst := new(StructWithSlices)
*dst = *src
dst.Values = append(src.Values[:0:0], src.Values...)
dst.ValuePointers = make([]*StructWithoutPtrs, len(src.ValuePointers))
for i := range dst.ValuePointers {
dst.ValuePointers[i] = src.ValuePointers[i].Clone()
}
dst.StructPointers = make([]*StructWithPtrs, len(src.StructPointers))
for i := range dst.StructPointers {
dst.StructPointers[i] = src.StructPointers[i].Clone()
}
dst.Structs = make([]StructWithPtrs, len(src.Structs))
for i := range dst.Structs {
dst.Structs[i] = *src.Structs[i].Clone()
}
dst.Ints = make([]*int, len(src.Ints))
for i := range dst.Ints {
x := *src.Ints[i]
dst.Ints[i] = &x
}
dst.Slice = append(src.Slice[:0:0], src.Slice...)
dst.Prefixes = append(src.Prefixes[:0:0], src.Prefixes...)
dst.Data = append(src.Data[:0:0], src.Data...)
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithSlicesCloneNeedsRegeneration = StructWithSlices(struct {
Values []StructWithoutPtrs
ValuePointers []*StructWithoutPtrs
StructPointers []*StructWithPtrs
Structs []StructWithPtrs
Ints []*int
Slice []string
Prefixes []netaddr.IPPrefix
Data []byte
}{})

View File

@@ -0,0 +1,316 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
package tests
import (
"encoding/json"
"errors"
"go4.org/mem"
"inet.af/netaddr"
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices
// View returns a readonly view of StructWithPtrs.
func (p *StructWithPtrs) View() StructWithPtrsView {
return StructWithPtrsView{ж: p}
}
// StructWithPtrsView provides a read-only view over StructWithPtrs.
//
// Its methods should only be called if `Valid()` returns true.
type StructWithPtrsView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *StructWithPtrs
}
// Valid reports whether underlying value is non-nil.
func (v StructWithPtrsView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v StructWithPtrsView) AsStruct() *StructWithPtrs {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v StructWithPtrsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *StructWithPtrsView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x StructWithPtrs
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v StructWithPtrsView) Value() *StructWithoutPtrs {
if v.ж.Value == nil {
return nil
}
x := *v.ж.Value
return &x
}
func (v StructWithPtrsView) Int() *int {
if v.ж.Int == nil {
return nil
}
x := *v.ж.Int
return &x
}
func (v StructWithPtrsView) NoCloneValue() *StructWithoutPtrs { return v.ж.NoCloneValue }
func (v StructWithPtrsView) String() string { return v.ж.String() }
func (v StructWithPtrsView) Equal(v2 StructWithPtrsView) bool { return v.ж.Equal(v2.ж) }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithPtrsViewNeedsRegeneration = StructWithPtrs(struct {
Value *StructWithoutPtrs
Int *int
NoCloneValue *StructWithoutPtrs
}{})
// View returns a readonly view of StructWithoutPtrs.
func (p *StructWithoutPtrs) View() StructWithoutPtrsView {
return StructWithoutPtrsView{ж: p}
}
// StructWithoutPtrsView provides a read-only view over StructWithoutPtrs.
//
// Its methods should only be called if `Valid()` returns true.
type StructWithoutPtrsView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *StructWithoutPtrs
}
// Valid reports whether underlying value is non-nil.
func (v StructWithoutPtrsView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v StructWithoutPtrsView) AsStruct() *StructWithoutPtrs {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v StructWithoutPtrsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *StructWithoutPtrsView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x StructWithoutPtrs
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v StructWithoutPtrsView) Int() int { return v.ж.Int }
func (v StructWithoutPtrsView) Pfx() netaddr.IPPrefix { return v.ж.Pfx }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithoutPtrsViewNeedsRegeneration = StructWithoutPtrs(struct {
Int int
Pfx netaddr.IPPrefix
}{})
// View returns a readonly view of Map.
func (p *Map) View() MapView {
return MapView{ж: p}
}
// MapView provides a read-only view over Map.
//
// Its methods should only be called if `Valid()` returns true.
type MapView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *Map
}
// Valid reports whether underlying value is non-nil.
func (v MapView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v MapView) AsStruct() *Map {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v MapView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *MapView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x Map
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v MapView) Int() views.Map[string, int] { return views.MapOf(v.ж.Int) }
func (v MapView) SliceInt() views.MapFn[string, []int, views.Slice[int]] {
return views.MapFnOf(v.ж.SliceInt, func(t []int) views.Slice[int] {
return views.SliceOf(t)
})
}
func (v MapView) StructWithPtr() views.MapFn[string, *StructWithPtrs, StructWithPtrsView] {
return views.MapFnOf(v.ж.StructWithPtr, func(t *StructWithPtrs) StructWithPtrsView {
return t.View()
})
}
func (v MapView) StructWithoutPtr() views.MapFn[string, *StructWithoutPtrs, StructWithoutPtrsView] {
return views.MapFnOf(v.ж.StructWithoutPtr, func(t *StructWithoutPtrs) StructWithoutPtrsView {
return t.View()
})
}
func (v MapView) SlicesWithPtrs() views.MapFn[string, []*StructWithPtrs, views.SliceView[*StructWithPtrs, StructWithPtrsView]] {
return views.MapFnOf(v.ж.SlicesWithPtrs, func(t []*StructWithPtrs) views.SliceView[*StructWithPtrs, StructWithPtrsView] {
return views.SliceOfViews[*StructWithPtrs, StructWithPtrsView](t)
})
}
func (v MapView) SlicesWithoutPtrs() views.MapFn[string, []*StructWithoutPtrs, views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView]] {
return views.MapFnOf(v.ж.SlicesWithoutPtrs, func(t []*StructWithoutPtrs) views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView] {
return views.SliceOfViews[*StructWithoutPtrs, StructWithoutPtrsView](t)
})
}
func (v MapView) StructWithoutPtrKey() views.Map[StructWithoutPtrs, int] {
return views.MapOf(v.ж.StructWithoutPtrKey)
}
func (v MapView) SliceIntPtr() map[string][]*int { panic("unsupported") }
func (v MapView) PointerKey() map[*string]int { panic("unsupported") }
func (v MapView) StructWithPtrKey() map[StructWithPtrs]int { panic("unsupported") }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _MapViewNeedsRegeneration = Map(struct {
Int map[string]int
SliceInt map[string][]int
StructWithPtr map[string]*StructWithPtrs
StructWithoutPtr map[string]*StructWithoutPtrs
SlicesWithPtrs map[string][]*StructWithPtrs
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
StructWithoutPtrKey map[StructWithoutPtrs]int
SliceIntPtr map[string][]*int
PointerKey map[*string]int
StructWithPtrKey map[StructWithPtrs]int
}{})
// View returns a readonly view of StructWithSlices.
func (p *StructWithSlices) View() StructWithSlicesView {
return StructWithSlicesView{ж: p}
}
// StructWithSlicesView provides a read-only view over StructWithSlices.
//
// Its methods should only be called if `Valid()` returns true.
type StructWithSlicesView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *StructWithSlices
}
// Valid reports whether underlying value is non-nil.
func (v StructWithSlicesView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v StructWithSlicesView) AsStruct() *StructWithSlices {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v StructWithSlicesView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *StructWithSlicesView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x StructWithSlices
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v StructWithSlicesView) Values() views.Slice[StructWithoutPtrs] {
return views.SliceOf(v.ж.Values)
}
func (v StructWithSlicesView) ValuePointers() views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView] {
return views.SliceOfViews[*StructWithoutPtrs, StructWithoutPtrsView](v.ж.ValuePointers)
}
func (v StructWithSlicesView) StructPointers() views.SliceView[*StructWithPtrs, StructWithPtrsView] {
return views.SliceOfViews[*StructWithPtrs, StructWithPtrsView](v.ж.StructPointers)
}
func (v StructWithSlicesView) Structs() StructWithPtrs { panic("unsupported") }
func (v StructWithSlicesView) Ints() *int { panic("unsupported") }
func (v StructWithSlicesView) Slice() views.Slice[string] { return views.SliceOf(v.ж.Slice) }
func (v StructWithSlicesView) Prefixes() views.IPPrefixSlice {
return views.IPPrefixSliceOf(v.ж.Prefixes)
}
func (v StructWithSlicesView) Data() mem.RO { return mem.B(v.ж.Data) }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithSlicesViewNeedsRegeneration = StructWithSlices(struct {
Values []StructWithoutPtrs
ValuePointers []*StructWithoutPtrs
StructPointers []*StructWithPtrs
Structs []StructWithPtrs
Ints []*int
Slice []string
Prefixes []netaddr.IPPrefix
Data []byte
}{})

378
cmd/viewer/viewer.go Normal file
View File

@@ -0,0 +1,378 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Viewer is a tool to automate the creation of "view" wrapper types that
// provide read-only accessor methods to underlying fields.
package main
import (
"bytes"
"flag"
"fmt"
"go/types"
"html/template"
"log"
"os"
"strings"
"tailscale.com/util/codegen"
)
const viewTemplateStr = `{{define "common"}}
// View returns a readonly view of {{.StructName}}.
func (p *{{.StructName}}) View() {{.ViewName}} {
return {{.ViewName}}{ж: p}
}
// {{.ViewName}} provides a read-only view over {{.StructName}}.
//
// Its methods should only be called if ` + "`Valid()`" + ` returns true.
type {{.ViewName}} struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *{{.StructName}}
}
// Valid reports whether underlying value is non-nil.
func (v {{.ViewName}}) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v {{.ViewName}}) AsStruct() *{{.StructName}}{
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v {{.ViewName}}) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *{{.ViewName}}) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x {{.StructName}}
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж=&x
return nil
}
{{end}}
{{define "valueField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} { return v.ж.{{.FieldName}} }
{{end}}
{{define "byteSliceField"}}func (v {{.ViewName}}) {{.FieldName}}() mem.RO { return mem.B(v.ж.{{.FieldName}}) }
{{end}}
{{define "ipPrefixSliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.IPPrefixSlice { return views.IPPrefixSliceOf(v.ж.{{.FieldName}}) }
{{end}}
{{define "sliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.Slice[{{.FieldType}}] { return views.SliceOf(v.ж.{{.FieldName}}) }
{{end}}
{{define "viewSliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.SliceView[{{.FieldType}},{{.FieldViewName}}] { return views.SliceOfViews[{{.FieldType}},{{.FieldViewName}}](v.ж.{{.FieldName}}) }
{{end}}
{{define "viewField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}}View { return v.ж.{{.FieldName}}.View() }
{{end}}
{{define "valuePointerField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} {
if v.ж.{{.FieldName}} == nil {
return nil
}
x := *v.ж.{{.FieldName}}
return &x
}
{{end}}
{{define "mapField"}}
func(v {{.ViewName}}) {{.FieldName}}() views.Map[{{.MapKeyType}},{{.MapValueType}}] { return views.MapOf(v.ж.{{.FieldName}})}
{{end}}
{{define "mapFnField"}}
func(v {{.ViewName}}) {{.FieldName}}() views.MapFn[{{.MapKeyType}},{{.MapValueType}},{{.MapValueView}}] { return views.MapFnOf(v.ж.{{.FieldName}}, func (t {{.MapValueType}}) {{.MapValueView}} {
return {{.MapFn}}
})}
{{end}}
{{define "unsupportedField"}}func(v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} {panic("unsupported")}
{{end}}
{{define "stringFunc"}}func(v {{.ViewName}}) String() string { return v.ж.String() }
{{end}}
{{define "equalFunc"}}func(v {{.ViewName}}) Equal(v2 {{.ViewName}}) bool { return v.ж.Equal(v2.ж) }
{{end}}
`
var viewTemplate *template.Template
func init() {
viewTemplate = template.Must(template.New("view").Parse(viewTemplateStr))
}
func requiresCloning(t types.Type) (shallow, deep bool, base types.Type) {
switch v := t.(type) {
case *types.Pointer:
_, deep, base = requiresCloning(v.Elem())
return true, deep, base
case *types.Slice:
_, deep, base = requiresCloning(v.Elem())
return true, deep, base
}
p := codegen.ContainsPointers(t)
return p, p, t
}
func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thisPkg *types.Package) {
t, ok := typ.Underlying().(*types.Struct)
if !ok || codegen.IsViewType(t) {
return
}
it.Import("encoding/json")
it.Import("errors")
args := struct {
StructName string
ViewName string
FieldName string
FieldType string
FieldViewName string
MapKeyType string
MapValueType string
MapValueView string
MapFn string
}{
StructName: typ.Obj().Name(),
ViewName: typ.Obj().Name() + "View",
}
writeTemplate := func(name string) {
if err := viewTemplate.ExecuteTemplate(buf, name, args); err != nil {
log.Fatal(err)
}
}
writeTemplate("common")
for i := 0; i < t.NumFields(); i++ {
f := t.Field(i)
fname := f.Name()
if !f.Exported() {
continue
}
args.FieldName = fname
fieldType := f.Type()
if codegen.IsInvalid(fieldType) {
continue
}
if !codegen.ContainsPointers(fieldType) || codegen.IsViewType(fieldType) || codegen.HasNoClone(t.Tag(i)) {
args.FieldType = it.QualifiedName(fieldType)
writeTemplate("valueField")
continue
}
switch underlying := fieldType.Underlying().(type) {
case *types.Slice:
slice := underlying
elem := slice.Elem()
args.FieldType = it.QualifiedName(elem)
switch elem.String() {
case "byte":
it.Import("go4.org/mem")
writeTemplate("byteSliceField")
case "inet.af/netaddr.IPPrefix":
it.Import("tailscale.com/types/views")
writeTemplate("ipPrefixSliceField")
default:
it.Import("tailscale.com/types/views")
shallow, deep, base := requiresCloning(elem)
if deep {
if _, isPtr := elem.(*types.Pointer); isPtr {
args.FieldViewName = it.QualifiedName(base) + "View"
writeTemplate("viewSliceField")
} else {
writeTemplate("unsupportedField")
}
continue
} else if shallow {
if _, isBasic := base.(*types.Basic); isBasic {
writeTemplate("unsupportedField")
} else {
args.FieldViewName = it.QualifiedName(base) + "View"
writeTemplate("viewSliceField")
}
continue
}
writeTemplate("sliceField")
}
continue
case *types.Struct, *types.Named:
strucT := underlying
args.FieldType = it.QualifiedName(fieldType)
if codegen.ContainsPointers(strucT) {
writeTemplate("viewField")
continue
}
writeTemplate("valueField")
continue
case *types.Map:
m := underlying
args.FieldType = it.QualifiedName(fieldType)
shallow, deep, key := requiresCloning(m.Key())
if shallow || deep {
writeTemplate("unsupportedField")
continue
}
args.MapKeyType = it.QualifiedName(key)
mElem := m.Elem()
var template string
switch u := mElem.(type) {
case *types.Basic:
template = "mapField"
args.MapValueType = it.QualifiedName(mElem)
case *types.Slice:
slice := u
sElem := slice.Elem()
switch x := sElem.(type) {
case *types.Basic:
args.MapValueView = fmt.Sprintf("views.Slice[%v]", sElem)
args.MapValueType = "[]" + sElem.String()
args.MapFn = "views.SliceOf(t)"
template = "mapFnField"
case *types.Pointer:
ptr := x
pElem := ptr.Elem()
switch pElem.(type) {
case *types.Struct, *types.Named:
ptrType := it.QualifiedName(ptr)
viewType := it.QualifiedName(pElem) + "View"
args.MapFn = fmt.Sprintf("views.SliceOfViews[%v,%v](t)", ptrType, viewType)
args.MapValueView = fmt.Sprintf("views.SliceView[%v,%v]", ptrType, viewType)
args.MapValueType = "[]" + ptrType
template = "mapFnField"
default:
template = "unsupportedField"
}
default:
template = "unsupportedField"
}
case *types.Pointer:
ptr := u
pElem := ptr.Elem()
switch pElem.(type) {
case *types.Struct, *types.Named:
args.MapValueType = it.QualifiedName(ptr)
args.MapValueView = it.QualifiedName(pElem) + "View"
args.MapFn = "t.View()"
template = "mapFnField"
default:
template = "unsupportedField"
}
default:
template = "unsupportedField"
}
writeTemplate(template)
continue
case *types.Pointer:
ptr := underlying
_, deep, base := requiresCloning(ptr)
if deep {
args.FieldType = it.QualifiedName(base)
writeTemplate("viewField")
} else {
args.FieldType = it.QualifiedName(ptr)
writeTemplate("valuePointerField")
}
continue
}
writeTemplate("unsupportedField")
}
for i := 0; i < typ.NumMethods(); i++ {
f := typ.Method(i)
if !f.Exported() {
continue
}
sig, ok := f.Type().(*types.Signature)
if !ok {
continue
}
switch f.Name() {
case "Clone", "View":
continue // "AsStruct"
case "String":
writeTemplate("stringFunc")
continue
case "Equal":
if sig.Results().Len() == 1 && sig.Results().At(0).Type().String() == "bool" {
writeTemplate("equalFunc")
continue
}
}
}
fmt.Fprintf(buf, "\n")
buf.Write(codegen.AssertStructUnchanged(t, args.StructName, "View", it))
}
var (
flagTypes = flag.String("type", "", "comma-separated list of types; required")
flagBuildTags = flag.String("tags", "", "compiler build tags to apply")
flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func")
)
func main() {
log.SetFlags(0)
log.SetPrefix("viewer: ")
flag.Parse()
if len(*flagTypes) == 0 {
flag.Usage()
os.Exit(2)
}
typeNames := strings.Split(*flagTypes, ",")
var flagArgs []string
flagArgs = append(flagArgs, fmt.Sprintf("-clonefunc=%v", *flagCloneFunc))
if *flagTypes != "" {
flagArgs = append(flagArgs, "-type="+*flagTypes)
}
if *flagBuildTags != "" {
flagArgs = append(flagArgs, "-tags="+*flagBuildTags)
}
pkg, namedTypes, err := codegen.LoadTypes(*flagBuildTags, ".")
if err != nil {
log.Fatal(err)
}
it := codegen.NewImportTracker(pkg.Types)
buf := new(bytes.Buffer)
fmt.Fprintf(buf, `//go:generate go run tailscale.com/cmd/cloner %s`, strings.Join(flagArgs, " "))
fmt.Fprintln(buf)
runCloner := false
for _, typeName := range typeNames {
typ, ok := namedTypes[typeName]
if !ok {
log.Fatalf("could not find type %s", typeName)
}
var hasClone bool
for i, n := 0, typ.NumMethods(); i < n; i++ {
if typ.Method(i).Name() == "Clone" {
hasClone = true
break
}
}
if !hasClone {
runCloner = true
}
genView(buf, it, typ, pkg.Types)
}
out := pkg.Name + "_view.go"
if err := codegen.WritePackageFile("tailscale/cmd/viewer", pkg, out, it, buf); err != nil {
log.Fatal(err)
}
if runCloner {
// When a new pacakge is added or when existing generated files have
// been deleted, we might run into a case where tailscale.com/cmd/cloner
// has not run yet. We detect this by verifying that all the structs we
// interacted with have had Clone method already generated. If they
// haven't we ask the caller to rerun generation again so that those get
// generated.
log.Printf("%v requires regeneration. Please run go generate again", pkg.Name+"_clone.go")
}
}

View File

@@ -6,6 +6,7 @@ package controlclient
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
@@ -41,30 +42,31 @@ func (g *LoginGoal) sendLogoutError(err error) {
}
}
var _ Client = (*Auto)(nil)
// Auto connects to a tailcontrol server for a node.
// It's a concrete implementation of the Client interface.
type Auto struct {
direct *Direct // our interface to the server APIs
timeNow func() time.Time
logf logger.Logf
expiry *time.Time
closed bool
newMapCh chan struct{} // readable when we must restart a map request
direct *Direct // our interface to the server APIs
timeNow func() time.Time
logf logger.Logf
expiry *time.Time
closed bool
newMapCh chan struct{} // readable when we must restart a map request
statusFunc func(Status) // called to update Client status; always non-nil
unregisterHealthWatch func()
mu sync.Mutex // mutex guards the following fields
statusFunc func(Status) // called to update Client status
mu sync.Mutex // mutex guards the following fields
paused bool // whether we should stop making HTTP requests
unpauseWaiters []chan struct{}
loggedIn bool // true if currently logged in
loginGoal *LoginGoal // non-nil if some login activity is desired
synced bool // true if our netmap is up-to-date
hostinfo *tailcfg.Hostinfo
inPollNetMap bool // true if currently running a PollNetMap
inLiteMapUpdate bool // true if a lite (non-streaming) map request is outstanding
inSendStatus int // number of sendStatus calls currently in progress
inPollNetMap bool // true if currently running a PollNetMap
inLiteMapUpdate bool // true if a lite (non-streaming) map request is outstanding
inSendStatus int // number of sendStatus calls currently in progress
state State
authCtx context.Context // context used for auth requests
@@ -91,6 +93,9 @@ func NewNoStart(opts Options) (*Auto, error) {
if err != nil {
return nil, err
}
if opts.Status == nil {
return nil, errors.New("missing required Options.Status")
}
if opts.Logf == nil {
opts.Logf = func(fmt string, args ...any) {}
}
@@ -98,13 +103,14 @@ func NewNoStart(opts Options) (*Auto, error) {
opts.TimeNow = time.Now
}
c := &Auto{
direct: direct,
timeNow: opts.TimeNow,
logf: opts.Logf,
newMapCh: make(chan struct{}, 1),
quit: make(chan struct{}),
authDone: make(chan struct{}),
mapDone: make(chan struct{}),
direct: direct,
timeNow: opts.TimeNow,
logf: opts.Logf,
newMapCh: make(chan struct{}, 1),
quit: make(chan struct{}),
authDone: make(chan struct{}),
mapDone: make(chan struct{}),
statusFunc: opts.Status,
}
c.authCtx, c.authCancel = context.WithCancel(context.Background())
c.mapCtx, c.mapCancel = context.WithCancel(context.Background())
@@ -290,6 +296,7 @@ func (c *Auto) authRoutine() {
}
if goal == nil {
health.SetAuthRoutineInError(nil)
// Wait for user to Login or Logout.
<-ctx.Done()
c.logf("[v1] authRoutine: context done.")
@@ -297,6 +304,7 @@ func (c *Auto) authRoutine() {
}
if !goal.wantLoggedIn {
health.SetAuthRoutineInError(nil)
err := c.direct.TryLogout(ctx)
goal.sendLogoutError(err)
if err != nil {
@@ -335,6 +343,7 @@ func (c *Auto) authRoutine() {
f = "TryLogin"
}
if err != nil {
health.SetAuthRoutineInError(err)
report(err, f)
bo.BackOff(ctx, err)
continue
@@ -359,6 +368,7 @@ func (c *Auto) authRoutine() {
}
// success
health.SetAuthRoutineInError(nil)
c.mu.Lock()
c.loggedIn = true
c.loginGoal = nil
@@ -459,7 +469,7 @@ func (c *Auto) mapRoutine() {
c.mu.Unlock()
health.SetInPollNetMap(false)
err := c.direct.PollNetMap(ctx, -1, func(nm *netmap.NetworkMap) {
err := c.direct.PollNetMap(ctx, func(nm *netmap.NetworkMap) {
health.SetInPollNetMap(true)
c.mu.Lock()
@@ -528,13 +538,6 @@ func (c *Auto) AuthCantContinue() bool {
return !c.loggedIn && (c.loginGoal == nil || c.loginGoal.url != "")
}
// SetStatusFunc sets fn as the callback to run on any status change.
func (c *Auto) SetStatusFunc(fn func(Status)) {
c.mu.Lock()
c.statusFunc = fn
c.mu.Unlock()
}
func (c *Auto) SetHostinfo(hi *tailcfg.Hostinfo) {
if hi == nil {
panic("nil Hostinfo")
@@ -555,19 +558,20 @@ func (c *Auto) SetNetInfo(ni *tailcfg.NetInfo) {
if !c.direct.SetNetInfo(ni) {
return
}
c.logf("NetInfo: %v", ni)
// Send new Hostinfo (which includes NetInfo) to server
// Send new NetInfo to server
c.sendNewMapRequest()
}
func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkMap) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return
}
state := c.state
loggedIn := c.loggedIn
synced := c.synced
statusFunc := c.statusFunc
hi := c.hostinfo
c.inSendStatus++
c.mu.Unlock()
@@ -595,13 +599,10 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
URL: url,
Persist: p,
NetMap: nm,
Hostinfo: hi,
State: state,
Err: err,
}
if statusFunc != nil {
statusFunc(new)
}
c.statusFunc(new)
c.mu.Lock()
c.inSendStatus--
@@ -666,11 +667,8 @@ func (c *Auto) SetExpirySooner(ctx context.Context, expiry time.Time) error {
// them to the control server if they've changed.
//
// It does not retain the provided slice.
//
// The localPort field is unused except for integration tests in
// another repo.
func (c *Auto) UpdateEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) {
changed := c.direct.SetEndpoints(localPort, endpoints)
func (c *Auto) UpdateEndpoints(endpoints []tailcfg.Endpoint) {
changed := c.direct.SetEndpoints(endpoints)
if changed {
c.sendNewMapRequest()
}
@@ -685,7 +683,6 @@ func (c *Auto) Shutdown() {
direct := c.direct
if !closed {
c.closed = true
c.statusFunc = nil
}
c.mu.Unlock()

View File

@@ -11,8 +11,6 @@ package controlclient
import (
"context"
"net/http"
"time"
"tailscale.com/tailcfg"
)
@@ -29,9 +27,6 @@ const (
// Currently this is done through a pair of polling https requests in
// the Auto client, but that might change eventually.
type Client interface {
// SetStatusFunc provides a callback to call when control sends us
// a message.
SetStatusFunc(func(Status))
// Shutdown closes this session, which should not be used any further
// afterwards.
Shutdown()
@@ -47,9 +42,6 @@ type Client interface {
// Logout starts a synchronous logout process. It doesn't return
// until the logout operation has been completed.
Logout(context.Context) error
// SetExpirySooner sets the node's expiry time via the controlclient,
// as long as it's shorter than the current expiry time.
SetExpirySooner(context.Context, time.Time) error
// SetPaused pauses or unpauses the controlclient activity as much
// as possible, without losing its internal state, to minimize
// unnecessary network activity.
@@ -75,17 +67,10 @@ type Client interface {
SetNetInfo(*tailcfg.NetInfo)
// UpdateEndpoints changes the Endpoint structure that will be sent
// in subsequent node registration requests.
// The localPort field is unused except for integration tests in another repo.
// TODO: a server-side change would let us simply upload this
// in a separate http request. It has nothing to do with the rest of
// the state machine.
UpdateEndpoints(localPort uint16, endpoints []tailcfg.Endpoint)
// SetDNS sends the SetDNSRequest request to the control plane server,
// requesting a DNS record be created or updated.
SetDNS(context.Context, *tailcfg.SetDNSRequest) error
// DoNoiseRequest sends an HTTP request to the control plane
// over the Noise transport.
DoNoiseRequest(*http.Request) (*http.Response, error)
UpdateEndpoints(endpoints []tailcfg.Endpoint)
}
// UserVisibleError is an error that should be shown to users.

View File

@@ -22,7 +22,7 @@ func fieldsOf(t reflect.Type) (fields []string) {
func TestStatusEqual(t *testing.T) {
// Verify that the Equal method stays in sync with reality
equalHandles := []string{"LoginFinished", "LogoutFinished", "Err", "URL", "NetMap", "State", "Persist", "Hostinfo"}
equalHandles := []string{"LoginFinished", "LogoutFinished", "Err", "URL", "NetMap", "State", "Persist"}
if have := fieldsOf(reflect.TypeOf(Status{})); !reflect.DeepEqual(have, equalHandles) {
t.Errorf("Status.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
have, equalHandles)

View File

@@ -26,7 +26,6 @@ import (
"time"
"go4.org/mem"
"golang.org/x/sync/singleflight"
"inet.af/netaddr"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
@@ -38,9 +37,9 @@ import (
"tailscale.com/net/dnscache"
"tailscale.com/net/dnsfallback"
"tailscale.com/net/interfaces"
"tailscale.com/net/netns"
"tailscale.com/net/netutil"
"tailscale.com/net/tlsdial"
"tailscale.com/net/tsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
@@ -50,6 +49,7 @@ import (
"tailscale.com/types/persist"
"tailscale.com/util/clientmetric"
"tailscale.com/util/multierr"
"tailscale.com/util/singleflight"
"tailscale.com/util/systemd"
"tailscale.com/wgengine/monitor"
)
@@ -57,7 +57,8 @@ import (
// Direct is the client that connects to a tailcontrol server for a node.
type Direct struct {
httpc *http.Client // HTTP client used to talk to tailcontrol
serverURL string // URL of the tailcontrol server
dialer *tsdial.Dialer
serverURL string // URL of the tailcontrol server
timeNow func() time.Time
lastPrintMap time.Time
newDecompressor func() (Decompressor, error)
@@ -76,18 +77,17 @@ type Direct struct {
serverKey key.MachinePublic // original ("legacy") nacl crypto_box-based public key
serverNoiseKey key.MachinePublic
sfGroup singleflight.Group // protects noiseClient creation.
sfGroup singleflight.Group[struct{}, *noiseClient] // protects noiseClient creation.
noiseClient *noiseClient
persist persist.Persist
authKey string
tryingNewKey key.NodePrivate
expiry *time.Time
// hostinfo is mutated in-place while mu is held.
persist persist.Persist
authKey string
tryingNewKey key.NodePrivate
expiry *time.Time
hostinfo *tailcfg.Hostinfo // always non-nil
netinfo *tailcfg.NetInfo
endpoints []tailcfg.Endpoint
everEndpoints bool // whether we've ever had non-empty endpoints
localPort uint16 // or zero to mean auto
lastPingURL string // last PingRequest.URL received, for dup suppression
}
@@ -106,6 +106,10 @@ type Options struct {
DebugFlags []string // debug settings to send to control
LinkMonitor *monitor.Mon // optional link monitor
PopBrowserURL func(url string) // optional func to open browser
Dialer *tsdial.Dialer // non-nil
// Status is called when there's a change in status.
Status func(Status)
// KeepSharerAndUserSplit controls whether the client
// understands Node.Sharer. If false, the Sharer is mapped to the User.
@@ -122,11 +126,10 @@ type Options struct {
Pinger Pinger
}
// Pinger is a subset of the wgengine.Engine interface, containing just the Ping method.
// Pinger is the LocalBackend.Ping method.
type Pinger interface {
// Ping is a request to start a discovery or TSMP ping with the peer handling
// the given IP and then call cb with its ping latency & method.
Ping(ip netaddr.IP, useTSMP bool, cb func(*ipnstate.PingResult))
// Ping is a request to do a ping with the peer handling the given IP.
Ping(ctx context.Context, ip netaddr.IP, pingType tailcfg.PingType) (*ipnstate.PingResult, error)
}
type Decompressor interface {
@@ -170,13 +173,12 @@ func NewDirect(opts Options) (*Direct, error) {
UseLastGood: true,
LookupIPFallback: dnsfallback.Lookup,
}
dialer := netns.NewDialer(opts.Logf)
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = tshttpproxy.ProxyFromEnvironment
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
tr.TLSClientConfig = tlsdial.Config(serverURL.Hostname(), tr.TLSClientConfig)
tr.DialContext = dnscache.Dialer(dialer.DialContext, dnsCache)
tr.DialTLSContext = dnscache.TLSDialer(dialer.DialContext, dnsCache, tr.TLSClientConfig)
tr.DialContext = dnscache.Dialer(opts.Dialer.SystemDial, dnsCache)
tr.DialTLSContext = dnscache.TLSDialer(opts.Dialer.SystemDial, dnsCache, tr.TLSClientConfig)
tr.ForceAttemptHTTP2 = true
// Disable implicit gzip compression; the various
// handlers (register, map, set-dns, etc) do their own
@@ -202,11 +204,17 @@ func NewDirect(opts Options) (*Direct, error) {
skipIPForwardingCheck: opts.SkipIPForwardingCheck,
pinger: opts.Pinger,
popBrowser: opts.PopBrowserURL,
dialer: opts.Dialer,
}
if opts.Hostinfo == nil {
c.SetHostinfo(hostinfo.New())
} else {
ni := opts.Hostinfo.NetInfo
opts.Hostinfo.NetInfo = nil
c.SetHostinfo(opts.Hostinfo)
if ni != nil {
c.SetNetInfo(ni)
}
}
return c, nil
}
@@ -251,14 +259,11 @@ func (c *Direct) SetNetInfo(ni *tailcfg.NetInfo) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.hostinfo == nil {
c.logf("[unexpected] SetNetInfo called with no HostInfo; ignoring NetInfo update: %+v", ni)
if reflect.DeepEqual(ni, c.netinfo) {
return false
}
if reflect.DeepEqual(ni, c.hostinfo.NetInfo) {
return false
}
c.hostinfo.NetInfo = ni.Clone()
c.netinfo = ni.Clone()
c.logf("NetInfo: %v", ni)
return true
}
@@ -335,6 +340,14 @@ type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
// hostInfoLocked returns a Clone of c.hostinfo and c.netinfo.
// It must only be called with c.mu held.
func (c *Direct) hostInfoLocked() *tailcfg.Hostinfo {
hi := c.hostinfo.Clone()
hi.NetInfo = c.netinfo.Clone()
return hi
}
func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, newURL string, err error) {
c.mu.Lock()
persist := c.persist
@@ -342,7 +355,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
serverKey := c.serverKey
serverNoiseKey := c.serverNoiseKey
authKey := c.authKey
hi := c.hostinfo.Clone()
hi := c.hostInfoLocked()
backendLogID := hi.BackendLogID
expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.timeNow())
c.mu.Unlock()
@@ -376,7 +389,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
if err != nil {
return regen, opt.URL, err
}
c.logf("control server key %s from %s", serverKey.ShortString(), c.serverURL)
c.logf("control server key from %s: ts2021=%s, legacy=%v", c.serverURL, keys.PublicKey.ShortString(), keys.LegacyPublicKey.ShortString())
c.mu.Lock()
c.serverKey = keys.LegacyPublicKey
@@ -575,20 +588,19 @@ func sameEndpoints(a, b []tailcfg.Endpoint) bool {
// whether they've changed.
//
// It does not retain the provided slice.
func (c *Direct) newEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) (changed bool) {
func (c *Direct) newEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
c.mu.Lock()
defer c.mu.Unlock()
// Nothing new?
if c.localPort == localPort && sameEndpoints(c.endpoints, endpoints) {
if sameEndpoints(c.endpoints, endpoints) {
return false // unchanged
}
var epStrs []string
for _, ep := range endpoints {
epStrs = append(epStrs, ep.Addr.String())
}
c.logf("[v2] client.newEndpoints(%v, %v)", localPort, epStrs)
c.localPort = localPort
c.logf("[v2] client.newEndpoints(%v)", epStrs)
c.endpoints = append(c.endpoints[:0], endpoints...)
if len(endpoints) > 0 {
c.everEndpoints = true
@@ -599,28 +611,37 @@ func (c *Direct) newEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) (c
// SetEndpoints updates the list of locally advertised endpoints.
// It won't be replicated to the server until a *fresh* call to PollNetMap().
// You don't need to restart PollNetMap if we return changed==false.
func (c *Direct) SetEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) (changed bool) {
func (c *Direct) SetEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
// (no log message on function entry, because it clutters the logs
// if endpoints haven't changed. newEndpoints() will log it.)
return c.newEndpoints(localPort, endpoints)
return c.newEndpoints(endpoints)
}
func inTest() bool { return flag.Lookup("test.v") != nil }
// PollNetMap makes a /map request to download the network map, calling cb with
// each new netmap.
//
// maxPolls is how many network maps to download; common values are 1
// or -1 (to keep a long-poll query open to the server).
func (c *Direct) PollNetMap(ctx context.Context, maxPolls int, cb func(*netmap.NetworkMap)) error {
return c.sendMapRequest(ctx, maxPolls, cb)
func (c *Direct) PollNetMap(ctx context.Context, cb func(*netmap.NetworkMap)) error {
return c.sendMapRequest(ctx, -1, false, cb)
}
// FetchNetMap fetches the netmap once.
func (c *Direct) FetchNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
var ret *netmap.NetworkMap
err := c.sendMapRequest(ctx, 1, false, func(nm *netmap.NetworkMap) {
ret = nm
})
if err == nil && ret == nil {
return nil, errors.New("[unexpected] sendMapRequest success without callback")
}
return ret, err
}
// SendLiteMapUpdate makes a /map request to update the server of our latest state,
// but does not fetch anything. It returns an error if the server did not return a
// successful 200 OK response.
func (c *Direct) SendLiteMapUpdate(ctx context.Context) error {
return c.sendMapRequest(ctx, 1, nil)
return c.sendMapRequest(ctx, 1, false, nil)
}
// If we go more than pollTimeout without hearing from the server,
@@ -629,7 +650,7 @@ func (c *Direct) SendLiteMapUpdate(ctx context.Context) error {
const pollTimeout = 120 * time.Second
// cb nil means to omit peers.
func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netmap.NetworkMap)) error {
func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool, cb func(*netmap.NetworkMap)) error {
metricMapRequests.Add(1)
metricMapRequestsActive.Add(1)
defer metricMapRequestsActive.Add(-1)
@@ -644,9 +665,8 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
serverURL := c.serverURL
serverKey := c.serverKey
serverNoiseKey := c.serverNoiseKey
hi := c.hostinfo.Clone()
hi := c.hostInfoLocked()
backendLogID := hi.BackendLogID
localPort := c.localPort
var epStrs []string
var epTypes []tailcfg.EndpointType
for _, ep := range c.endpoints {
@@ -672,7 +692,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
}
allowStream := maxPolls != 1
c.logf("[v1] PollNetMap: stream=%v :%v ep=%v", allowStream, localPort, epStrs)
c.logf("[v1] PollNetMap: stream=%v ep=%v", allowStream, epStrs)
vlogf := logger.Discard
if Debug.NetMap {
@@ -692,6 +712,16 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
Hostinfo: hi,
DebugFlags: c.debugFlags,
OmitPeers: cb == nil,
// On initial startup before we know our endpoints, set the ReadOnly flag
// to tell the control server not to distribute out our (empty) endpoints to peers.
// Presumably we'll learn our endpoints in a half second and do another post
// with useful results. The first POST just gets us the DERP map which we
// need to do the STUN queries to discover our endpoints.
// TODO(bradfitz): we skip this optimization in tests, though,
// because the e2e tests are currently hyperspecific about the
// ordering of things. The e2e tests need love.
ReadOnly: readOnly || (len(epStrs) == 0 && !everEndpoints && !inTest()),
}
var extraDebugFlags []string
if hi != nil && c.linkMon != nil && !c.skipIPForwardingCheck &&
@@ -714,17 +744,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
if c.newDecompressor != nil {
request.Compress = "zstd"
}
// On initial startup before we know our endpoints, set the ReadOnly flag
// to tell the control server not to distribute out our (empty) endpoints to peers.
// Presumably we'll learn our endpoints in a half second and do another post
// with useful results. The first POST just gets us the DERP map which we
// need to do the STUN queries to discover our endpoints.
// TODO(bradfitz): we skip this optimization in tests, though,
// because the e2e tests are currently hyperspecific about the
// ordering of things. The e2e tests need love.
if len(epStrs) == 0 && !everEndpoints && !inTest() {
request.ReadOnly = true
}
bodyData, err := encode(request, serverKey, serverNoiseKey, machinePrivKey)
if err != nil {
@@ -929,14 +948,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
nm.SelfNode.Capabilities = nil
}
// Get latest localPort. This might've changed if
// a lite map update occurred meanwhile. This only affects
// the end-to-end test.
// TODO(bradfitz): remove the NetworkMap.LocalPort field entirely.
c.mu.Lock()
nm.LocalPort = c.localPort
c.mu.Unlock()
// Occasionally print the netmap header.
// This is handy for debugging, and our logs processing
// pipeline depends on it. (TODO: Remove this dependency.)
@@ -1195,12 +1206,9 @@ func answerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinge
return
}
for _, t := range strings.Split(pr.Types, ",") {
switch t {
case "TSMP", "disco":
go doPingerPing(logf, c, pr, pinger, t)
// TODO(tailscale/corp#754)
// case "host":
// case "peerapi":
switch pt := tailcfg.PingType(t); pt {
case tailcfg.PingTSMP, tailcfg.PingDisco, tailcfg.PingICMP, tailcfg.PingPeerAPI:
go doPingerPing(logf, c, pr, pinger, pt)
default:
logf("unsupported ping request type: %q", t)
}
@@ -1272,13 +1280,12 @@ func (c *Direct) getNoiseClient() (*noiseClient, error) {
if nc != nil {
return nc, nil
}
np, err, _ := c.sfGroup.Do("noise", func() (any, error) {
nc, err, _ := c.sfGroup.Do(struct{}{}, func() (*noiseClient, error) {
k, err := c.getMachinePrivKey()
if err != nil {
return nil, err
}
nc, err = newNoiseClient(k, serverNoiseKey, c.serverURL)
nc, err := newNoiseClient(k, serverNoiseKey, c.serverURL, c.dialer)
if err != nil {
return nil, err
}
@@ -1290,7 +1297,7 @@ func (c *Direct) getNoiseClient() (*noiseClient, error) {
if err != nil {
return nil, err
}
return np.(*noiseClient), nil
return nc, nil
}
// setDNSNoise sends the SetDNSRequest request to the control plane server over Noise,
@@ -1400,16 +1407,23 @@ func (c *Direct) DoNoiseRequest(req *http.Request) (*http.Response, error) {
// doPingerPing sends a Ping to pr.IP using pinger, and sends an http request back to
// pr.URL with ping response data.
func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinger Pinger, pingType string) {
func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinger Pinger, pingType tailcfg.PingType) {
if pr.URL == "" || pr.IP.IsZero() || pinger == nil {
logf("invalid ping request: missing url, ip or pinger")
return
}
start := time.Now()
pinger.Ping(pr.IP, pingType == "TSMP", func(res *ipnstate.PingResult) {
// Currently does not check for error since we just return if it fails.
postPingResult(start, logf, c, pr, res.ToPingResponse(pingType))
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := pinger.Ping(ctx, pr.IP, pingType)
if err != nil {
d := time.Since(start).Round(time.Millisecond)
logf("doPingerPing: ping error of type %q to %v after %v: %v", pingType, pr.IP, d, err)
return
}
postPingResult(start, logf, c, pr, res.ToPingResponse(pingType))
}
func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, res *tailcfg.PingResponse) error {

View File

@@ -14,6 +14,7 @@ import (
"inet.af/netaddr"
"tailscale.com/hostinfo"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
@@ -30,6 +31,7 @@ func TestNewDirect(t *testing.T) {
GetMachinePrivateKey: func() (key.MachinePrivate, error) {
return k, nil
},
Dialer: new(tsdial.Dialer),
}
c, err := NewDirect(opts)
if err != nil {
@@ -66,22 +68,18 @@ func TestNewDirect(t *testing.T) {
}
endpoints := fakeEndpoints(1, 2, 3)
changed = c.newEndpoints(12, endpoints)
changed = c.newEndpoints(endpoints)
if !changed {
t.Errorf("c.newEndpoints(12) want true got %v", changed)
t.Errorf("c.newEndpoints want true got %v", changed)
}
changed = c.newEndpoints(12, endpoints)
changed = c.newEndpoints(endpoints)
if changed {
t.Errorf("c.newEndpoints(12) want false got %v", changed)
}
changed = c.newEndpoints(13, endpoints)
if !changed {
t.Errorf("c.newEndpoints(13) want true got %v", changed)
t.Errorf("c.newEndpoints want false got %v", changed)
}
endpoints = fakeEndpoints(4, 5, 6)
changed = c.newEndpoints(13, endpoints)
changed = c.newEndpoints(endpoints)
if !changed {
t.Errorf("c.newEndpoints(13) want true got %v", changed)
t.Errorf("c.newEndpoints want true got %v", changed)
}
}
@@ -106,6 +104,7 @@ func TestTsmpPing(t *testing.T) {
GetMachinePrivateKey: func() (key.MachinePrivate, error) {
return k, nil
},
Dialer: new(tsdial.Dialer),
}
c, err := NewDirect(opts)

View File

@@ -5,6 +5,7 @@
package controlclient
import (
"fmt"
"log"
"sort"
@@ -165,12 +166,6 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo
}
ms.addUserProfile(peer.User)
}
if len(resp.DNS) > 0 {
nm.DNS.Nameservers = resp.DNS
}
if len(resp.SearchPaths) > 0 {
nm.DNS.Domains = resp.SearchPaths
}
if Debug.ProxyDNS {
nm.DNS.Proxied = true
}
@@ -244,7 +239,7 @@ func undeltaPeers(mapRes *tailcfg.MapResponse, prev []*tailcfg.Node) {
sortNodes(newFull)
}
if len(mapRes.PeerSeenChange) != 0 || len(mapRes.OnlineChange) != 0 {
if len(mapRes.PeerSeenChange) != 0 || len(mapRes.OnlineChange) != 0 || len(mapRes.PeersChangedPatch) != 0 {
peerByID := make(map[tailcfg.NodeID]*tailcfg.Node, len(newFull))
for _, n := range newFull {
peerByID[n.ID] = n
@@ -265,6 +260,16 @@ func undeltaPeers(mapRes *tailcfg.MapResponse, prev []*tailcfg.Node) {
n.Online = &online
}
}
for _, ec := range mapRes.PeersChangedPatch {
if n, ok := peerByID[ec.NodeID]; ok {
if ec.DERPRegion != 0 {
n.DERP = fmt.Sprintf("%s:%v", tailcfg.DerpMagicIP, ec.DERPRegion)
}
if ec.Endpoints != nil {
n.Endpoints = ec.Endpoints
}
}
}
}
mapRes.Peers = newFull

View File

@@ -34,6 +34,16 @@ func TestUndeltaPeers(t *testing.T) {
n.LastSeen = &t
}
}
withDERP := func(d string) func(*tailcfg.Node) {
return func(n *tailcfg.Node) {
n.DERP = d
}
}
withEP := func(ep string) func(*tailcfg.Node) {
return func(n *tailcfg.Node) {
n.Endpoints = []string{ep}
}
}
n := func(id tailcfg.NodeID, name string, mod ...func(*tailcfg.Node)) *tailcfg.Node {
n := &tailcfg.Node{ID: id, Name: name}
for _, f := range mod {
@@ -137,7 +147,53 @@ func TestUndeltaPeers(t *testing.T) {
n(2, "bar", seenAt(time.Unix(123, 0))),
),
},
{
name: "ep_change_derp",
prev: peers(n(1, "foo", withDERP("127.3.3.40:3"))),
mapRes: &tailcfg.MapResponse{
PeersChangedPatch: []*tailcfg.PeerChange{{
NodeID: 1,
DERPRegion: 4,
}},
},
want: peers(n(1, "foo", withDERP("127.3.3.40:4"))),
},
{
name: "ep_change_udp",
prev: peers(n(1, "foo", withEP("1.2.3.4:111"))),
mapRes: &tailcfg.MapResponse{
PeersChangedPatch: []*tailcfg.PeerChange{{
NodeID: 1,
Endpoints: []string{"1.2.3.4:56"},
}},
},
want: peers(n(1, "foo", withEP("1.2.3.4:56"))),
},
{
name: "ep_change_udp",
prev: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:111"))),
mapRes: &tailcfg.MapResponse{
PeersChangedPatch: []*tailcfg.PeerChange{{
NodeID: 1,
Endpoints: []string{"1.2.3.4:56"},
}},
},
want: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:56"))),
},
{
name: "ep_change_both",
prev: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:111"))),
mapRes: &tailcfg.MapResponse{
PeersChangedPatch: []*tailcfg.PeerChange{{
NodeID: 1,
DERPRegion: 2,
Endpoints: []string{"1.2.3.4:56"},
}},
},
want: peers(n(1, "foo", withDERP("127.3.3.40:2"), withEP("1.2.3.4:56"))),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !tt.curTime.IsZero() {

View File

@@ -18,8 +18,10 @@ import (
"golang.org/x/net/http2"
"tailscale.com/control/controlbase"
"tailscale.com/control/controlhttp"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/util/mak"
"tailscale.com/util/multierr"
)
@@ -45,6 +47,7 @@ func (c *noiseConn) Close() error {
// the ts2021 protocol.
type noiseClient struct {
*http.Client // HTTP client used to talk to tailcontrol
dialer *tsdial.Dialer
privKey key.MachinePrivate
serverPubKey key.MachinePublic
serverHost string // the host:port part of serverURL
@@ -57,7 +60,7 @@ type noiseClient struct {
// newNoiseClient returns a new noiseClient for the provided server and machine key.
// serverURL is of the form https://<host>:<port> (no trailing slash).
func newNoiseClient(priKey key.MachinePrivate, serverPubKey key.MachinePublic, serverURL string) (*noiseClient, error) {
func newNoiseClient(priKey key.MachinePrivate, serverPubKey key.MachinePublic, serverURL string, dialer *tsdial.Dialer) (*noiseClient, error) {
u, err := url.Parse(serverURL)
if err != nil {
return nil, err
@@ -74,6 +77,7 @@ func newNoiseClient(priKey key.MachinePrivate, serverPubKey key.MachinePublic, s
serverPubKey: serverPubKey,
privKey: priKey,
serverHost: host,
dialer: dialer,
}
// Create the HTTP/2 Transport using a net/http.Transport
@@ -137,9 +141,6 @@ func (nc *noiseClient) Close() error {
func (nc *noiseClient) dial(_, _ string, _ *tls.Config) (net.Conn, error) {
nc.mu.Lock()
connID := nc.nextID
if nc.connPool == nil {
nc.connPool = make(map[int]*noiseConn)
}
nc.nextID++
nc.mu.Unlock()
@@ -153,7 +154,7 @@ func (nc *noiseClient) dial(_, _ string, _ *tls.Config) (net.Conn, error) {
// thousand version numbers before getting to this point.
panic("capability version is too high to fit in the wire protocol")
}
conn, err := controlhttp.Dial(ctx, nc.serverHost, nc.privKey, nc.serverPubKey, uint16(tailcfg.CurrentCapabilityVersion))
conn, err := controlhttp.Dial(ctx, nc.serverHost, nc.privKey, nc.serverPubKey, uint16(tailcfg.CurrentCapabilityVersion), nc.dialer.SystemDial)
if err != nil {
return nil, err
}
@@ -161,6 +162,6 @@ func (nc *noiseClient) dial(_, _ string, _ *tls.Config) (net.Conn, error) {
nc.mu.Lock()
defer nc.mu.Unlock()
ncc := &noiseConn{Conn: conn, id: connID, pool: nc}
nc.connPool[ncc.id] = ncc
mak.Set(&nc.connPool, ncc.id, ncc)
return ncc, nil
}

View File

@@ -9,7 +9,6 @@ import (
"fmt"
"reflect"
"tailscale.com/tailcfg"
"tailscale.com/types/empty"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
@@ -75,9 +74,8 @@ type Status struct {
// package, but we have some automated tests elsewhere that need to
// use them. Please don't use these fields.
// TODO(apenwarr): Unexport or remove these.
State State
Persist *persist.Persist // locally persisted configuration
Hostinfo *tailcfg.Hostinfo // current Hostinfo data
State State
Persist *persist.Persist // locally persisted configuration
}
// Equal reports whether s and s2 are equal.
@@ -92,7 +90,6 @@ func (s *Status) Equal(s2 *Status) bool {
s.URL == s2.URL &&
reflect.DeepEqual(s.Persist, s2.Persist) &&
reflect.DeepEqual(s.NetMap, s2.NetMap) &&
reflect.DeepEqual(s.Hostinfo, s2.Hostinfo) &&
s.State == s2.State
}

View File

@@ -2,6 +2,9 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !js
// +build !js
// Package controlhttp implements the Tailscale 2021 control protocol
// base transport over HTTP.
//
@@ -25,37 +28,21 @@ import (
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptrace"
"net/url"
"time"
"tailscale.com/control/controlbase"
"tailscale.com/net/dnscache"
"tailscale.com/net/dnsfallback"
"tailscale.com/net/netns"
"tailscale.com/net/netutil"
"tailscale.com/net/tlsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/types/key"
)
const (
// upgradeHeader is the value of the Upgrade HTTP header used to
// indicate the Tailscale control protocol.
upgradeHeaderValue = "tailscale-control-protocol"
// handshakeHeaderName is the HTTP request header that can
// optionally contain base64-encoded initial handshake
// payload, to save an RTT.
handshakeHeaderName = "X-Tailscale-Handshake"
// serverUpgradePath is where the server-side HTTP handler to
// to do the protocol switch is located.
serverUpgradePath = "/ts2021"
)
// Dial connects to the HTTP server at addr, requests to switch to the
// Tailscale control protocol, and returns an established control
// protocol connection.
@@ -65,13 +52,12 @@ const (
//
// The provided ctx is only used for the initial connection, until
// Dial returns. It does not affect the connection once established.
func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, controlKey key.MachinePublic, protocolVersion uint16) (*controlbase.Conn, error) {
func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, controlKey key.MachinePublic, protocolVersion uint16, dialer dnscache.DialContextFunc) (*controlbase.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
a := &dialParams{
ctx: ctx,
host: host,
httpPort: port,
httpsPort: "443",
@@ -79,12 +65,12 @@ func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, contr
controlKey: controlKey,
version: protocolVersion,
proxyFunc: tshttpproxy.ProxyFromEnvironment,
dialer: dialer,
}
return a.dial()
return a.dial(ctx)
}
type dialParams struct {
ctx context.Context
host string
httpPort string
httpsPort string
@@ -92,65 +78,132 @@ type dialParams struct {
controlKey key.MachinePublic
version uint16
proxyFunc func(*http.Request) (*url.URL, error) // or nil
dialer dnscache.DialContextFunc
// For tests only
insecureTLS bool
insecureTLS bool
testFallbackDelay time.Duration
}
func (a *dialParams) dial() (*controlbase.Conn, error) {
init, cont, err := controlbase.ClientDeferred(a.machineKey, a.controlKey, a.version)
if err != nil {
return nil, err
// httpsFallbackDelay is how long we'll wait for a.httpPort to work before
// starting to try a.httpsPort.
func (a *dialParams) httpsFallbackDelay() time.Duration {
if v := a.testFallbackDelay; v != 0 {
return v
}
return 500 * time.Millisecond
}
u := &url.URL{
func (a *dialParams) dial(ctx context.Context) (*controlbase.Conn, error) {
// Create one shared context used by both port 80 and port 443 dials.
// If port 80 is still in flight when 443 returns, this deferred cancel
// will stop the port 80 dial.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// u80 and u443 are the URLs we'll try to hit over HTTP or HTTPS,
// respectively, in order to do the HTTP upgrade to a net.Conn over which
// we'll speak Noise.
u80 := &url.URL{
Scheme: "http",
Host: net.JoinHostPort(a.host, a.httpPort),
Path: serverUpgradePath,
}
conn, httpErr := a.tryURL(u, init)
if httpErr == nil {
ret, err := cont(a.ctx, conn)
if err != nil {
conn.Close()
return nil, err
}
return ret, nil
u443 := &url.URL{
Scheme: "https",
Host: net.JoinHostPort(a.host, a.httpsPort),
Path: serverUpgradePath,
}
// Connecting over plain HTTP failed, assume it's an HTTP proxy
// being difficult and see if we can get through over HTTPS.
u.Scheme = "https"
u.Host = net.JoinHostPort(a.host, a.httpsPort)
init, cont, err = controlbase.ClientDeferred(a.machineKey, a.controlKey, a.version)
type tryURLRes struct {
u *url.URL // input (the URL conn+err are for/from)
conn *controlbase.Conn // result (mutually exclusive with err)
err error
}
ch := make(chan tryURLRes) // must be unbuffered
try := func(u *url.URL) {
cbConn, err := a.dialURL(ctx, u)
select {
case ch <- tryURLRes{u, cbConn, err}:
case <-ctx.Done():
if cbConn != nil {
cbConn.Close()
}
}
}
// Start the plaintext HTTP attempt first.
go try(u80)
// In case outbound port 80 blocked or MITM'ed poorly, start a backup timer
// to dial port 443 if port 80 doesn't either succeed or fail quickly.
try443Timer := time.AfterFunc(a.httpsFallbackDelay(), func() { try(u443) })
defer try443Timer.Stop()
var err80, err443 error
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("connection attempts aborted by context: %w", ctx.Err())
case res := <-ch:
if res.err == nil {
return res.conn, nil
}
switch res.u {
case u80:
// Connecting over plain HTTP failed; assume it's an HTTP proxy
// being difficult and see if we can get through over HTTPS.
err80 = res.err
// Stop the fallback timer and run it immediately. We don't use
// Timer.Reset(0) here because on AfterFuncs, that can run it
// again.
if try443Timer.Stop() {
go try(u443)
} // else we lost the race and it started already which is what we want
case u443:
err443 = res.err
default:
panic("invalid")
}
if err80 != nil && err443 != nil {
return nil, fmt.Errorf("all connection attempts failed (HTTP: %v, HTTPS: %v)", err80, err443)
}
}
}
}
// dialURL attempts to connect to the given URL.
func (a *dialParams) dialURL(ctx context.Context, u *url.URL) (*controlbase.Conn, error) {
init, cont, err := controlbase.ClientDeferred(a.machineKey, a.controlKey, a.version)
if err != nil {
return nil, err
}
conn, tlsErr := a.tryURL(u, init)
if tlsErr == nil {
ret, err := cont(a.ctx, conn)
if err != nil {
conn.Close()
return nil, err
}
return ret, nil
netConn, err := a.tryURLUpgrade(ctx, u, init)
if err != nil {
return nil, err
}
return nil, fmt.Errorf("all connection attempts failed (HTTP: %v, HTTPS: %v)", httpErr, tlsErr)
cbConn, err := cont(ctx, netConn)
if err != nil {
netConn.Close()
return nil, err
}
return cbConn, nil
}
func (a *dialParams) tryURL(u *url.URL, init []byte) (net.Conn, error) {
// tryURLUpgrade connects to u, and tries to upgrade it to a net.Conn.
//
// Only the provided ctx is used, not a.ctx.
func (a *dialParams) tryURLUpgrade(ctx context.Context, u *url.URL, init []byte) (net.Conn, error) {
dns := &dnscache.Resolver{
Forward: dnscache.Get().Forward,
LookupIPFallback: dnsfallback.Lookup,
UseLastGood: true,
}
dialer := netns.NewDialer(log.Printf)
tr := http.DefaultTransport.(*http.Transport).Clone()
defer tr.CloseIdleConnections()
tr.Proxy = a.proxyFunc
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
tr.DialContext = dnscache.Dialer(dialer.DialContext, dns)
tr.DialContext = dnscache.Dialer(a.dialer, dns)
// Disable HTTP2, since h2 can't do protocol switching.
tr.TLSClientConfig.NextProtos = []string{}
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
@@ -159,7 +212,7 @@ func (a *dialParams) tryURL(u *url.URL, init []byte) (net.Conn, error) {
tr.TLSClientConfig.InsecureSkipVerify = true
tr.TLSClientConfig.VerifyConnection = nil
}
tr.DialTLSContext = dnscache.TLSDialer(dialer.DialContext, dns, tr.TLSClientConfig)
tr.DialTLSContext = dnscache.TLSDialer(a.dialer, dns, tr.TLSClientConfig)
tr.DisableCompression = true
// (mis)use httptrace to extract the underlying net.Conn from the
@@ -189,7 +242,7 @@ func (a *dialParams) tryURL(u *url.URL, init []byte) (net.Conn, error) {
connCh <- info.Conn
},
}
ctx := httptrace.WithClientTrace(a.ctx, &trace)
ctx = httptrace.WithClientTrace(ctx, &trace)
req := &http.Request{
Method: "POST",
URL: u,

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package controlhttp
import (
"context"
"encoding/base64"
"net"
"net/url"
"nhooyr.io/websocket"
"tailscale.com/control/controlbase"
"tailscale.com/net/dnscache"
"tailscale.com/types/key"
)
// Variant of Dial that tunnels the request over WebSockets, since we cannot do
// bi-directional communication over an HTTP connection when in JS.
func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, controlKey key.MachinePublic, protocolVersion uint16, dialer dnscache.DialContextFunc) (*controlbase.Conn, error) {
init, cont, err := controlbase.ClientDeferred(machineKey, controlKey, protocolVersion)
if err != nil {
return nil, err
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
wsScheme := "wss"
wsHost := host
if host == "localhost" {
wsScheme = "ws"
wsHost = addr
}
wsURL := &url.URL{
Scheme: wsScheme,
Host: wsHost,
Path: serverUpgradePath,
// Can't set HTTP headers on the websocket request, so we have to to send
// the handshake via an HTTP header.
RawQuery: url.Values{
handshakeHeaderName: []string{base64.StdEncoding.EncodeToString(init)},
}.Encode(),
}
wsConn, _, err := websocket.Dial(ctx, wsURL.String(), &websocket.DialOptions{
Subprotocols: []string{upgradeHeaderValue},
})
if err != nil {
return nil, err
}
netConn := websocket.NetConn(context.Background(), wsConn, websocket.MessageBinary)
cbConn, err := cont(ctx, netConn)
if err != nil {
netConn.Close()
return nil, err
}
return cbConn, nil
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package controlhttp
const (
// upgradeHeader is the value of the Upgrade HTTP header used to
// indicate the Tailscale control protocol.
upgradeHeaderValue = "tailscale-control-protocol"
// handshakeHeaderName is the HTTP request header that can
// optionally contain base64-encoded initial handshake
// payload, to save an RTT.
handshakeHeaderName = "X-Tailscale-Handshake"
// serverUpgradePath is where the server-side HTTP handler to
// to do the protocol switch is located.
serverUpgradePath = "/ts2021"
)

View File

@@ -17,22 +17,36 @@ import (
"strconv"
"sync"
"testing"
"time"
"tailscale.com/control/controlbase"
"tailscale.com/net/socks5"
"tailscale.com/net/tsdial"
"tailscale.com/types/key"
)
type httpTestParam struct {
name string
proxy proxy
// makeHTTPHangAfterUpgrade makes the HTTP response hang after sending a
// 101 switching protocols.
makeHTTPHangAfterUpgrade bool
}
func TestControlHTTP(t *testing.T) {
tests := []struct {
name string
proxy proxy
}{
tests := []httpTestParam{
// direct connection
{
name: "no_proxy",
proxy: nil,
},
// direct connection but port 80 is MITM'ed and broken
{
name: "port80_broken_mitm",
proxy: nil,
makeHTTPHangAfterUpgrade: true,
},
// SOCKS5
{
name: "socks5",
@@ -96,12 +110,13 @@ func TestControlHTTP(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
testControlHTTP(t, test.proxy)
testControlHTTP(t, test)
})
}
}
func testControlHTTP(t *testing.T, proxy proxy) {
func testControlHTTP(t *testing.T, param httpTestParam) {
proxy := param.proxy
client, server := key.NewMachine(), key.NewMachine()
const testProtocolVersion = 1
@@ -132,7 +147,11 @@ func testControlHTTP(t *testing.T, proxy proxy) {
t.Fatalf("HTTPS listen: %v", err)
}
httpServer := &http.Server{Handler: handler}
var httpHandler http.Handler = handler
if param.makeHTTPHangAfterUpgrade {
httpHandler = http.HandlerFunc(brokenMITMHandler)
}
httpServer := &http.Server{Handler: httpHandler}
go httpServer.Serve(httpLn)
defer httpServer.Close()
@@ -143,18 +162,24 @@ func testControlHTTP(t *testing.T, proxy proxy) {
go httpsServer.ServeTLS(httpsLn, "", "")
defer httpsServer.Close()
//ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
//defer cancel()
ctx := context.Background()
const debugTimeout = false
if debugTimeout {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
}
a := dialParams{
ctx: context.Background(), //ctx,
host: "localhost",
httpPort: strconv.Itoa(httpLn.Addr().(*net.TCPAddr).Port),
httpsPort: strconv.Itoa(httpsLn.Addr().(*net.TCPAddr).Port),
machineKey: client,
controlKey: server.Public(),
version: testProtocolVersion,
insecureTLS: true,
host: "localhost",
httpPort: strconv.Itoa(httpLn.Addr().(*net.TCPAddr).Port),
httpsPort: strconv.Itoa(httpsLn.Addr().(*net.TCPAddr).Port),
machineKey: client,
controlKey: server.Public(),
version: testProtocolVersion,
insecureTLS: true,
dialer: new(tsdial.Dialer).SystemDial,
testFallbackDelay: 50 * time.Millisecond,
}
if proxy != nil {
@@ -173,7 +198,7 @@ func testControlHTTP(t *testing.T, proxy proxy) {
}
}
conn, err := a.dial()
conn, err := a.dial(ctx)
if err != nil {
t.Fatalf("dialing controlhttp: %v", err)
}
@@ -215,6 +240,7 @@ type proxy interface {
type socksProxy struct {
sync.Mutex
closed bool
proxy socks5.Server
ln net.Listener
clientConnAddrs map[string]bool // addrs of the local end of outgoing conns from proxy
@@ -230,7 +256,14 @@ func (s *socksProxy) Start(t *testing.T) (url string) {
}
s.ln = ln
s.clientConnAddrs = map[string]bool{}
s.proxy.Logf = t.Logf
s.proxy.Logf = func(format string, a ...any) {
s.Lock()
defer s.Unlock()
if s.closed {
return
}
t.Logf(format, a...)
}
s.proxy.Dialer = s.dialAndRecord
go s.proxy.Serve(ln)
return fmt.Sprintf("socks5://%s", ln.Addr().String())
@@ -239,6 +272,10 @@ func (s *socksProxy) Start(t *testing.T) (url string) {
func (s *socksProxy) Close() {
s.Lock()
defer s.Unlock()
if s.closed {
return
}
s.closed = true
s.ln.Close()
}
@@ -398,3 +435,11 @@ EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
Certificates: []tls.Certificate{cert},
}
}
func brokenMITMHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Upgrade", upgradeHeaderValue)
w.Header().Set("Connection", "upgrade")
w.WriteHeader(http.StatusSwitchingProtocols)
w.(http.Flusher).Flush()
<-r.Context().Done()
}

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"net/http"
"nhooyr.io/websocket"
"tailscale.com/control/controlbase"
"tailscale.com/net/netutil"
"tailscale.com/types/key"
@@ -27,6 +28,9 @@ func AcceptHTTP(ctx context.Context, w http.ResponseWriter, r *http.Request, pri
http.Error(w, "missing next protocol", http.StatusBadRequest)
return nil, errors.New("no next protocol in HTTP request")
}
if next == "websocket" {
return acceptWebsocket(ctx, w, r, private)
}
if next != upgradeHeaderValue {
http.Error(w, "unknown next protocol", http.StatusBadRequest)
return nil, fmt.Errorf("client requested unhandled next protocol %q", next)
@@ -71,3 +75,42 @@ func AcceptHTTP(ctx context.Context, w http.ResponseWriter, r *http.Request, pri
return nc, nil
}
// acceptWebsocket upgrades a WebSocket connection (from a client that cannot
// speak HTTP) to a Tailscale control protocol base transport connection.
func acceptWebsocket(ctx context.Context, w http.ResponseWriter, r *http.Request, private key.MachinePrivate) (*controlbase.Conn, error) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{upgradeHeaderValue},
OriginPatterns: []string{"*"},
})
if err != nil {
return nil, fmt.Errorf("Could not accept WebSocket connection %v", err)
}
if c.Subprotocol() != upgradeHeaderValue {
c.Close(websocket.StatusPolicyViolation, "client must speak the control subprotocol")
return nil, fmt.Errorf("Unexpected subprotocol %q", c.Subprotocol())
}
if err := r.ParseForm(); err != nil {
c.Close(websocket.StatusPolicyViolation, "Could not parse parameters")
return nil, fmt.Errorf("parse query parameters: %v", err)
}
initB64 := r.Form.Get(handshakeHeaderName)
if initB64 == "" {
c.Close(websocket.StatusPolicyViolation, "missing Tailscale handshake parameter")
return nil, errors.New("no tailscale handshake parameter in HTTP request")
}
init, err := base64.StdEncoding.DecodeString(initB64)
if err != nil {
c.Close(websocket.StatusPolicyViolation, "invalid tailscale handshake parameter")
return nil, fmt.Errorf("decoding base64 handshake parameter: %v", err)
}
conn := websocket.NetConn(ctx, c, websocket.MessageBinary)
nc, err := controlbase.Server(ctx, conn, private, init)
if err != nil {
conn.Close()
return nil, fmt.Errorf("noise handshake failed: %w", err)
}
return nc, nil
}

View File

@@ -156,6 +156,8 @@ func (c *Client) parseServerInfo(b []byte) (*serverInfo, error) {
}
type clientInfo struct {
// Version is the DERP protocol version that the client was built with.
// See the ProtocolVersion const.
Version int `json:"version,omitempty"`
// MeshKey optionally specifies a pre-shared key used by
@@ -485,7 +487,7 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
c.peeked = int(n)
} else {
// But if for some reason we read a large DERP message (which isn't necessarily
// a Wireguard packet), then just allocate memory for it.
// a WireGuard packet), then just allocate memory for it.
// TODO(bradfitz): use a pool if large frames ever happen in practice.
b = make([]byte, n)
_, err = io.ReadFull(c.br, b)

View File

@@ -410,7 +410,7 @@ func (s *Server) IsClientConnectedForTest(k key.NodePublic) bool {
// on its own.
//
// Accept closes nc.
func (s *Server) Accept(nc Conn, brw *bufio.ReadWriter, remoteAddr string) {
func (s *Server) Accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, remoteAddr string) {
closed := make(chan struct{})
s.mu.Lock()
@@ -428,7 +428,7 @@ func (s *Server) Accept(nc Conn, brw *bufio.ReadWriter, remoteAddr string) {
s.mu.Unlock()
}()
if err := s.accept(nc, brw, remoteAddr, connNum); err != nil && !s.isClosed() {
if err := s.accept(ctx, nc, brw, remoteAddr, connNum); err != nil && !s.isClosed() {
s.logf("derp: %s: %v", remoteAddr, err)
}
}
@@ -641,7 +641,7 @@ func (s *Server) addWatcher(c *sclient) {
go c.requestMeshUpdate()
}
func (s *Server) accept(nc Conn, brw *bufio.ReadWriter, remoteAddr string, connNum int64) error {
func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, remoteAddr string, connNum int64) error {
br := brw.Reader
nc.SetDeadline(time.Now().Add(10 * time.Second))
bw := &lazyBufioWriter{w: nc, lbw: brw.Writer}
@@ -660,7 +660,7 @@ func (s *Server) accept(nc Conn, brw *bufio.ReadWriter, remoteAddr string, connN
// At this point we trust the client so we don't time out.
nc.SetDeadline(time.Time{})
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(ctx)
defer cancel()
remoteIPPort, _ := netaddr.ParseIPPort(remoteAddr)

View File

@@ -85,8 +85,12 @@ func TestSendRecv(t *testing.T) {
t.Fatal(err)
}
defer cin.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
brwServer := bufio.NewReadWriter(bufio.NewReader(cin), bufio.NewWriter(cin))
go s.Accept(cin, brwServer, fmt.Sprintf("test-client-%d", i))
go s.Accept(ctx, cin, brwServer, fmt.Sprintf("test-client-%d", i))
key := clientPrivateKeys[i]
brw := bufio.NewReadWriter(bufio.NewReader(cout), bufio.NewWriter(cout))
@@ -231,10 +235,10 @@ func TestSendFreeze(t *testing.T) {
// Then cathy stops processing messsages.
// That should not interfere with alice talking to bob.
newClient := func(name string, k key.NodePrivate) (c *Client, clientConn nettest.Conn) {
newClient := func(ctx context.Context, name string, k key.NodePrivate) (c *Client, clientConn nettest.Conn) {
t.Helper()
c1, c2 := nettest.NewConn(name, 1024)
go s.Accept(c1, bufio.NewReadWriter(bufio.NewReader(c1), bufio.NewWriter(c1)), name)
go s.Accept(ctx, c1, bufio.NewReadWriter(bufio.NewReader(c1), bufio.NewWriter(c1)), name)
brw := bufio.NewReadWriter(bufio.NewReader(c2), bufio.NewWriter(c2))
c, err := NewClient(k, c2, brw, t.Logf)
@@ -245,14 +249,17 @@ func TestSendFreeze(t *testing.T) {
return c, c2
}
ctx, clientCtxCancel := context.WithCancel(context.Background())
defer clientCtxCancel()
aliceKey := key.NewNode()
aliceClient, aliceConn := newClient("alice", aliceKey)
aliceClient, aliceConn := newClient(ctx, "alice", aliceKey)
bobKey := key.NewNode()
bobClient, bobConn := newClient("bob", bobKey)
bobClient, bobConn := newClient(ctx, "bob", bobKey)
cathyKey := key.NewNode()
cathyClient, cathyConn := newClient("cathy", cathyKey)
cathyClient, cathyConn := newClient(ctx, "cathy", cathyKey)
var (
aliceCh = make(chan struct{}, 32)
@@ -455,7 +462,7 @@ func (ts *testServer) close(t *testing.T) error {
return nil
}
func newTestServer(t *testing.T) *testServer {
func newTestServer(t *testing.T, ctx context.Context) *testServer {
t.Helper()
logf := logger.WithPrefix(t.Logf, "derp-server: ")
s := NewServer(key.NewNode(), logf)
@@ -475,7 +482,7 @@ func newTestServer(t *testing.T) *testServer {
// TODO: register c in ts so Close also closes it?
go func(i int) {
brwServer := bufio.NewReadWriter(bufio.NewReader(c), bufio.NewWriter(c))
go s.Accept(c, brwServer, fmt.Sprintf("test-client-%d", i))
go s.Accept(ctx, c, brwServer, fmt.Sprintf("test-client-%d", i))
}(i)
}
}()
@@ -610,7 +617,10 @@ func (c *testClient) close(t *testing.T) {
// TestWatch tests the connection watcher mechanism used by regional
// DERP nodes to mesh up with each other.
func TestWatch(t *testing.T) {
ts := newTestServer(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ts := newTestServer(t, ctx)
defer ts.close(t)
w1 := newTestWatcher(t, ts, "w1")
@@ -1198,7 +1208,10 @@ func benchmarkSendRecvSize(b *testing.B, packetSize int) {
defer connIn.Close()
brwServer := bufio.NewReadWriter(bufio.NewReader(connIn), bufio.NewWriter(connIn))
go s.Accept(connIn, brwServer, "test-client")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.Accept(ctx, connIn, brwServer, "test-client")
brw := bufio.NewReadWriter(bufio.NewReader(connOut), bufio.NewWriter(connOut))
client, err := NewClient(k, connOut, brw, logger.Discard)
@@ -1354,7 +1367,10 @@ func TestClientSendRateLimiting(t *testing.T) {
}
func TestServerRepliesToPing(t *testing.T) {
ts := newTestServer(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ts := newTestServer(t, ctx)
defer ts.close(t)
tc := newRegularClient(t, ts, "alice")

View File

@@ -56,6 +56,6 @@ func Handler(s *derp.Server) http.Handler {
pubKey.UntypedHexString())
}
s.Accept(netConn, conn, netConn.RemoteAddr().String())
s.Accept(r.Context(), netConn, conn, netConn.RemoteAddr().String())
})
}

View File

@@ -13,7 +13,6 @@ import (
"net"
"nhooyr.io/websocket"
"tailscale.com/derp/wsconn"
)
func init() {
@@ -29,5 +28,6 @@ func dialWebsocket(ctx context.Context, urlStr string) (net.Conn, error) {
return nil, err
}
log.Printf("websocket: connected to %v", urlStr)
return wsconn.New(c), nil
netConn := websocket.NetConn(context.Background(), c, websocket.MessageBinary)
return netConn, nil
}

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package wsconn contains an adapter type that turns
// a websocket connection into a net.Conn.
package wsconn
import (
"context"
"net"
"sync"
"time"
"nhooyr.io/websocket"
)
// New returns a net.Conn wrapper around c,
// using c to send and receive binary messages with
// chunks of bytes with no defined framing, effectively
// discarding all WebSocket-level message framing.
func New(c *websocket.Conn) net.Conn {
return &websocketConn{c: c}
}
// websocketConn implements derp.Conn around a *websocket.Conn,
// treating a websocket.Conn as a byte stream, ignoring the WebSocket
// frame/message boundaries.
type websocketConn struct {
c *websocket.Conn
// rextra are extra bytes owned by the reader.
rextra []byte
mu sync.Mutex
rdeadline time.Time
cancelRead context.CancelFunc
}
func (wc *websocketConn) LocalAddr() net.Addr { return addr{} }
func (wc *websocketConn) RemoteAddr() net.Addr { return addr{} }
type addr struct{}
func (addr) Network() string { return "websocket" }
func (addr) String() string { return "websocket" }
func (wc *websocketConn) Read(p []byte) (n int, err error) {
// Drain any leftover from previously.
n = copy(p, wc.rextra)
if n > 0 {
wc.rextra = wc.rextra[n:]
return n, nil
}
var ctx context.Context
var cancel context.CancelFunc
wc.mu.Lock()
if dl := wc.rdeadline; !dl.IsZero() {
ctx, cancel = context.WithDeadline(context.Background(), wc.rdeadline)
} else {
ctx, cancel = context.WithDeadline(context.Background(), time.Now().Add(30*24*time.Hour))
wc.rdeadline = time.Time{}
}
wc.cancelRead = cancel
wc.mu.Unlock()
defer cancel()
_, buf, err := wc.c.Read(ctx)
n = copy(p, buf)
wc.rextra = buf[n:]
return n, err
}
func (wc *websocketConn) Write(p []byte) (n int, err error) {
err = wc.c.Write(context.Background(), websocket.MessageBinary, p)
if err != nil {
return 0, err
}
return len(p), nil
}
func (wc *websocketConn) Close() error { return wc.c.Close(websocket.StatusNormalClosure, "close") }
func (wc *websocketConn) SetDeadline(t time.Time) error {
wc.SetReadDeadline(t)
wc.SetWriteDeadline(t)
return nil
}
func (wc *websocketConn) SetReadDeadline(t time.Time) error {
wc.mu.Lock()
defer wc.mu.Unlock()
if !t.IsZero() && (wc.rdeadline.IsZero() || t.Before(wc.rdeadline)) && wc.cancelRead != nil {
wc.cancelRead()
}
wc.rdeadline = t
return nil
}
func (wc *websocketConn) SetWriteDeadline(t time.Time) error {
return nil
}

View File

@@ -1,7 +0,0 @@
# Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
FROM ghcr.io/tailscale/tailscale:latest
COPY run.sh /run.sh
CMD "/run.sh"

View File

@@ -1,38 +1,28 @@
# Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
# Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
ifndef IMAGE_TAG
$(error "IMAGE_TAG is not set")
endif
ROUTES ?= ""
TS_ROUTES ?= ""
SA_NAME ?= tailscale
KUBE_SECRET ?= tailscale
build:
@docker build . -t $(IMAGE_TAG)
push: build
@docker push $(IMAGE_TAG)
TS_KUBE_SECRET ?= tailscale
rbac:
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" role.yaml | kubectl apply -f -
@sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" role.yaml | kubectl apply -f -
@sed -e "s;{{SA_NAME}};$(SA_NAME);g" rolebinding.yaml | kubectl apply -f -
@sed -e "s;{{SA_NAME}};$(SA_NAME);g" sa.yaml | kubectl apply -f -
sidecar:
@kubectl delete -f sidecar.yaml --ignore-not-found --grace-period=0
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" sidecar.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{IMAGE_TAG}};$(IMAGE_TAG);g" | kubectl create -f-
@sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" sidecar.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | kubectl create -f-
userspace-sidecar:
@kubectl delete -f userspace-sidecar.yaml --ignore-not-found --grace-period=0
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" userspace-sidecar.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{IMAGE_TAG}};$(IMAGE_TAG);g" | kubectl create -f-
@sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" userspace-sidecar.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | kubectl create -f-
proxy:
@kubectl delete -f proxy.yaml --ignore-not-found --grace-period=0
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" proxy.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{IMAGE_TAG}};$(IMAGE_TAG);g" | sed -e "s;{{DEST_IP}};$(DEST_IP);g" | kubectl create -f-
kubectl delete -f proxy.yaml --ignore-not-found --grace-period=0
sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" proxy.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{TS_DEST_IP}};$(TS_DEST_IP);g" | kubectl create -f-
subnet-router:
@kubectl delete -f subnet.yaml --ignore-not-found --grace-period=0
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" subnet.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{IMAGE_TAG}};$(IMAGE_TAG);g" | sed -e "s;{{ROUTES}};$(ROUTES);g" | kubectl create -f-
@sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" subnet.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{TS_ROUTES}};$(TS_ROUTES);g" | kubectl create -f-

View File

@@ -15,19 +15,12 @@ There are quite a few ways of running Tailscale inside a Kubernetes Cluster, som
AUTH_KEY: tskey-...
```
1. Build and push the container
```bash
export IMAGE_TAG=tailscale-k8s:latest
make push
```
1. Tailscale (v1.16+) supports storing state inside a Kubernetes Secret.
Configure RBAC to allow the Tailscale pod to read/write the `tailscale` secret.
```bash
export SA_NAME=tailscale
export KUBE_SECRET=tailscale-auth
export TS_KUBE_SECRET=tailscale-auth
make rbac
```
@@ -82,11 +75,11 @@ Running a Tailscale proxy allows you to provide inbound connectivity to a Kubern
```bash
kubectl create deployment nginx --image nginx
kubectl expose deployment nginx --port 80
export DEST_IP="$(kubectl get svc nginx -o=jsonpath='{.spec.clusterIP}')"
export TS_DEST_IP="$(kubectl get svc nginx -o=jsonpath='{.spec.clusterIP}')"
```
**Using an existing service**
```bash
export DEST_IP="$(kubectl get svc <SVC_NAME> -o=jsonpath='{.spec.clusterIP}')"
export TS_DEST_IP="$(kubectl get svc <SVC_NAME> -o=jsonpath='{.spec.clusterIP}')"
```
1. Deploy the proxy pod
@@ -114,12 +107,12 @@ Running a Tailscale proxy allows you to provide inbound connectivity to a Kubern
Running a Tailscale [subnet router](https://tailscale.com/kb/1019/subnets/) allows you to access
the entire Kubernetes cluster network (assuming NetworkPolicies allow) over Tailscale.
1. Identify the Pod/Service CIDRs that cover your Kubernetes cluster. These will vary depending on [which CNI](https://kubernetes.io/docs/concepts/cluster-administration/networking/) you are using and on the Cloud Provider you are using. Add these to the `ROUTES` variable as comma-separated values.
1. Identify the Pod/Service CIDRs that cover your Kubernetes cluster. These will vary depending on [which CNI](https://kubernetes.io/docs/concepts/cluster-administration/networking/) you are using and on the Cloud Provider you are using. Add these to the `TS_ROUTES` variable as comma-separated values.
```bash
SERVICE_CIDR=10.20.0.0/16
POD_CIDR=10.42.0.0/15
export ROUTES=$SERVICE_CIDR,$POD_CIDR
export TS_ROUTES=$SERVICE_CIDR,$POD_CIDR
```
1. Deploy the subnet-router pod.

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