Compare commits

...

30 Commits

Author SHA1 Message Date
Will Norris
5e76660843 stash 2023-08-10 10:38:49 -07:00
Will Norris
9425312923 webui: add new webui package and use with --dev flag
Signed-off-by: Will Norris <will@tailscale.com>
2023-08-08 09:54:55 -07:00
Sonia Appasamy
49896cbdfa ipn/ipnlocal: add profile pic header to serve HTTP proxy
Fixes #8807

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-07 13:55:12 -04:00
Brad Fitzpatrick
c56e94af2d ipn: avoid useless no-op WriteState calls
Rather than make each ipn.StateStore implementation guard against
useless writes (a write of the same value that's already in the
store), do writes via a new wrapper that has a fast path for the
unchanged case.

This then fixes profileManager's flood of useless writes to AWS SSM,
etc.

Updates #8785

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-07 08:44:24 -07:00
License Updater
a3f11e7710 licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-08-06 18:51:21 -07:00
Flakes Updater
10acc06389 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-08-05 10:02:40 -07:00
Claire Wang
a17c45fd6e control: use tstime instead of time (#8595)
Updates #8587
Signed-off-by: Claire Wang <claire@tailscale.com>
2023-08-04 19:29:44 -04:00
License Updater
a8e32f1a4b licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-08-04 09:43:49 -07:00
Andrew Lytvynov
371e1ebf07 cmd/dist,release/dist: expose RPM signing hook (#8789)
Plumb a signing callback function to `unixpkgs.rpmTarget` to allow
signing RPMs. This callback is optional and RPMs will build unsigned if
not set, just as before.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-03 15:27:06 -07:00
Andrew Lytvynov
eb6883bb5a go.mod: upgrade nfpm to v2 (#8786)
Upgrade the nfpm package to the latest version to pick up
24a43c5ad7.
The upgrade is from v0 to v2, so there was some breakage to fix.
Generated packages should have the same contents as before.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-03 13:00:45 -07:00
Aaron Klotz
37925b3e7a go.mod, cmd/tailscaled, ipn/localapi, util/osdiag, util/winutil, util/winutil/authenticode: add Windows module list to OS-specific logs that are written upon bugreport
* We update wingoes to pick up new version information functionality
  (See pe/version.go in the https://github.com/dblohm7/wingoes repo);
* We move the existing LogSupportInfo code (including necessary syscall
  stubs) out of util/winutil into a new package, util/osdiag, and implement
  the public LogSupportInfo function may be implemented for other platforms
  as needed;
* We add a new reason argument to LogSupportInfo and wire that into
  localapi's bugreport implementation;
* We add module information to the Windows implementation of LogSupportInfo
  when reason indicates a bugreport. We enumerate all loaded modules in our
  process, and for each one we gather debug, authenticode signature, and
  version information.

Fixes #7802

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-08-03 11:33:14 -06:00
Sonia Appasamy
301e59f398 tailcfg,ipn/localapi,client/tailscale: add QueryFeature endpoint
Updates tailscale/corp#10577

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-02 16:56:49 -04:00
Brad Fitzpatrick
ab7749aed7 go.toolchain.rev: go1.21rc4 (now that VERSION file is updated upstream)
Updates #8419

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-02 09:44:27 -07:00
Andrew Lytvynov
f57cc19ba2 cmd/tailscale/cli: add latest version output to "tailscale version" (#8700)
Add optional `--upstream` flag to `tailscale version` to fetch the
latest upstream release version from `pkgs.tailscale.com`. This is
useful to diagnose `tailscale update` behavior or write other tooling.

Example output:
$ tailscale version --upstream --json
{
	"majorMinorPatch": "1.47.35",
	"short": "1.47.35",
	"long": "1.47.35-t6afffece8",
	"unstableBranch": true,
	"gitCommit": "6afffece8a32509aa7a4dc2972415ec58d8316de",
	"cap": 66,
	"upstream": "1.45.61"
}

Fixes #8669

RELNOTE=adds "tailscale version --upstream"

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-02 08:24:18 -07:00
License Updater
b4c1f039b6 licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-08-01 21:31:58 -07:00
Brad Fitzpatrick
c3b979a176 go.toolchain.rev: bump to ~go1.21rc4
Updates tailscale/go#69

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-01 21:19:24 -07:00
Sonia Appasamy
34bfd7b419 tailcfg: add CapabilityHTTPS const
A #cleanup

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-01 21:59:28 -04:00
Brad Fitzpatrick
66e46bf501 ipnlocal, net/*: deprecate interfaces.GetState, use netmon more for it
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-01 16:14:46 -07:00
License Updater
6d65c04987 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-08-01 14:06:13 -07:00
Tom DNetto
767e839db5 all: implement lock revoke-keys command
The revoke-keys command allows nodes with tailnet lock keys
to collaborate to erase the use of a compromised key, and remove trust
in it.

Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates ENG-1848
2023-08-01 15:37:55 -05:00
Aaron Klotz
7adf15f90e cmd/tailscale/cli, util/winutil/authenticode: flesh out authenticode support
Previously, tailscale upgrade was doing the bare minimum for checking
authenticode signatures via `WinVerifyTrustEx`. This is fine, but we can do
better:

* WinVerifyTrustEx verifies that the binary's signature is valid, but it doesn't
  determine *whose* signature is valid; tailscale upgrade should also ensure that
  the binary is actually signed *by us*.
* I added the ability to check the signatures of MSI files.
* In future PRs I will be adding diagnostic logging that lists details about
  every module (ie, DLL) loaded into our process. As part of that metadata, I
  want to be able to extract information about who signed the binaries.

This code is modelled on some C++ I wrote for Firefox back in the day. See
https://searchfox.org/mozilla-central/rev/27e4816536c891d85d63695025f2549fd7976392/toolkit/xre/dllservices/mozglue/Authenticode.cpp
for reference.

Fixes #8284

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-08-01 14:27:30 -06:00
Denton Gentry
ec9213a627 cmd/sniproxy: add client metrics
Count number of sessions, number of DNS queries answered
successfully and in error, and number of http->https redirects.

Updates #1748

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-08-01 12:14:01 -07:00
Andrew Lytvynov
eef15b4ffc cmd/dist,release/dist: sign release tarballs with an ECDSA key (#8759)
Pass an optional PEM-encoded ECDSA key to `cmd/dist` to sign all built
tarballs. The signature is stored next to the tarball with a `.sig`
extension.

Tested this with an `openssl`-generated key pair and verified the
resulting signature.

Updates #8760

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-07-31 15:47:00 -07:00
David Anderson
ed46442cb1 client/tailscale/apitype: document never-nil property of WhoIsResponse
Every time I use WhoIsResponse I end up writing mildly irritating nil-checking
for both Node and UserProfile, but it turns out our code guarantees that both
are non-nil in successful whois responses.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-31 10:15:44 -07:00
Brad Fitzpatrick
5ebb271322 derp/derphttp: add optional Client.BaseContext hook
Like net/http.Server.BaseContext, this lets callers specify a base
context for dials.

Updates tailscale/corp#12702

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-30 20:06:19 -07:00
Maisem Ali
058d427fa6 tailcfg: add helper to unmarshal PeerCap values
Updates #4217

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-07-29 19:31:44 -07:00
salman aljammaz
68f8e5678e wgengine/magicsock: remove dead code (#8745)
The nonce value is not read by anything, and di.sharedKey.Seal()
a few lines below generates its own. #cleanup

Signed-off-by: salman <salman@tailscale.com>
2023-07-29 18:53:33 +01:00
License Updater
0554deb48c licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-07-28 17:42:52 -04:00
David Anderson
6114247d0a types/logid: add a Compare method
Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-28 13:54:07 -07:00
David Anderson
52212f4323 all: update exp/slices and fix call sites
slices.SortFunc suffered a late-in-cycle API breakage.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-28 13:11:53 -07:00
93 changed files with 6102 additions and 1024 deletions

3
.gitignore vendored
View File

@@ -35,5 +35,8 @@ cmd/tailscaled/tailscaled
# Ignore direnv nix-shell environment cache
.direnv/
.vite/
webui/node_modules
/gocross
/dist

View File

@@ -10,6 +10,7 @@ import "tailscale.com/tailcfg"
const LocalAPIHost = "local-tailscaled.sock"
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
// In successful whois responses, Node and UserProfile are never nil.
type WhoIsResponse struct {
Node *tailcfg.Node
UserProfile *tailcfg.UserProfile

View File

@@ -961,6 +961,42 @@ func (lc *LocalClient) NetworkLockVerifySigningDeeplink(ctx context.Context, url
return decodeJSON[*tka.DeeplinkValidationResult](body)
}
// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
func (lc *LocalClient) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
vr := struct {
Keys []tkatype.KeyID
ForkFrom string
}{removeKeys, forkFrom.String()}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/generate-recovery-aum", 200, jsonBody(vr))
if err != nil {
return nil, fmt.Errorf("sending generate-recovery-aum: %w", err)
}
return body, nil
}
// NetworkLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
func (lc *LocalClient) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
r := bytes.NewReader(aum.Serialize())
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
if err != nil {
return nil, fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return body, nil
}
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *LocalClient) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
r := bytes.NewReader(aum.Serialize())
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
if err != nil {
return fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return nil
}
// SetServeConfig sets or replaces the serving settings.
// If config is nil, settings are cleared and serving is disabled.
func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
@@ -1088,6 +1124,27 @@ func (lc *LocalClient) DeleteProfile(ctx context.Context, profile ipn.ProfileID)
return err
}
// QueryFeature makes a request for instructions on how to enable a
// feature, such as Funnel, for the node's tailnet.
//
// This request itself does not directly enable the feature on behalf
// of the node, but rather returns information that can be presented
// to the acting user about where/how to enable the feature.
//
// If relevant, this includes a control URL the user can visit to
// explicitly consent to using the feature. LocalClient.WatchIPNBus
// can be used to block on the feature being enabled.
//
// 2023-08-02: Valid feature values are "serve" and "funnel".
func (lc *LocalClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
v := url.Values{"feature": {feature}}
body, err := lc.send(ctx, "POST", "/localapi/v0/query-feature?"+v.Encode(), 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*tailcfg.QueryFeatureResponse](body)
}
func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*ipnstate.DebugDERPRegionReport, error) {
v := url.Values{"region": {regionIDOrCode}}
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-derp-region?"+v.Encode(), 200, nil)

View File

@@ -47,7 +47,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/client/tailscale+
go4.org/netipx from tailscale.com/wgengine/filter
go4.org/netipx from tailscale.com/wgengine/filter+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
google.golang.org/protobuf/encoding/prototext from github.com/golang/protobuf/proto+
google.golang.org/protobuf/encoding/protowire from github.com/golang/protobuf/proto+

4
cmd/dist/dist.go vendored
View File

@@ -19,10 +19,10 @@ import (
var synologyPackageCenter bool
func getTargets() ([]dist.Target, error) {
func getTargets(signers unixpkgs.Signers) ([]dist.Target, error) {
var ret []dist.Target
ret = append(ret, unixpkgs.Targets()...)
ret = append(ret, unixpkgs.Targets(signers)...)
// Synology packages can be built either for sideloading, or for
// distribution by Synology in their package center. When
// distributed through the package center, apps can request

View File

@@ -11,35 +11,40 @@ import (
"os"
"strings"
"github.com/goreleaser/nfpm"
_ "github.com/goreleaser/nfpm/deb"
_ "github.com/goreleaser/nfpm/rpm"
"github.com/goreleaser/nfpm/v2"
_ "github.com/goreleaser/nfpm/v2/deb"
"github.com/goreleaser/nfpm/v2/files"
_ "github.com/goreleaser/nfpm/v2/rpm"
)
// parseFiles parses a comma-separated list of colon-separated pairs
// into a map of filePathOnDisk -> filePathInPackage.
func parseFiles(s string) (map[string]string, error) {
ret := map[string]string{}
// into files.Contents format.
func parseFiles(s string, typ string) (files.Contents, error) {
if len(s) == 0 {
return ret, nil
return nil, nil
}
var contents files.Contents
for _, f := range strings.Split(s, ",") {
fs := strings.Split(f, ":")
if len(fs) != 2 {
return nil, fmt.Errorf("unparseable file field %q", f)
}
ret[fs[0]] = fs[1]
contents = append(contents, &files.Content{Type: files.TypeFile, Source: fs[0], Destination: fs[1]})
}
return ret, nil
return contents, nil
}
func parseEmptyDirs(s string) []string {
func parseEmptyDirs(s string) files.Contents {
// strings.Split("", ",") would return []string{""}, which is not suitable:
// this would create an empty dir record with path "", breaking the package
if s == "" {
return nil
}
return strings.Split(s, ",")
var contents files.Contents
for _, d := range strings.Split(s, ",") {
contents = append(contents, &files.Content{Type: files.TypeDir, Destination: d})
}
return contents
}
func main() {
@@ -48,7 +53,7 @@ func main() {
description := flag.String("description", "The easiest, most secure, cross platform way to use WireGuard + oauth2 + 2FA/SSO", "package description")
goarch := flag.String("arch", "amd64", "GOARCH this package is for")
pkgType := flag.String("type", "deb", "type of package to build (deb or rpm)")
files := flag.String("files", "", "comma-separated list of files in src:dst form")
regularFiles := flag.String("files", "", "comma-separated list of files in src:dst form")
configFiles := flag.String("configs", "", "like --files, but for files marked as user-editable config files")
emptyDirs := flag.String("emptydirs", "", "comma-separated list of empty directories")
version := flag.String("version", "0.0.0", "version of the package")
@@ -60,15 +65,20 @@ func main() {
recommends := flag.String("recommends", "", "comma-separated list of packages this package recommends")
flag.Parse()
filesMap, err := parseFiles(*files)
filesList, err := parseFiles(*regularFiles, files.TypeFile)
if err != nil {
log.Fatalf("Parsing --files: %v", err)
}
configsMap, err := parseFiles(*configFiles)
configsList, err := parseFiles(*configFiles, files.TypeConfig)
if err != nil {
log.Fatalf("Parsing --configs: %v", err)
}
emptyDirList := parseEmptyDirs(*emptyDirs)
contents := append(filesList, append(configsList, emptyDirList...)...)
contents, err = files.PrepareForPackager(contents, 0, *pkgType, false)
if err != nil {
log.Fatalf("Building package contents: %v", err)
}
info := nfpm.WithDefaults(&nfpm.Info{
Name: *name,
Arch: *goarch,
@@ -79,9 +89,7 @@ func main() {
Homepage: "https://www.tailscale.com",
License: "MIT",
Overridables: nfpm.Overridables{
EmptyFolders: emptyDirList,
Files: filesMap,
ConfigFiles: configsMap,
Contents: contents,
Scripts: nfpm.Scripts{
PostInstall: *postinst,
PreRemove: *prerm,

View File

@@ -45,6 +45,7 @@ import (
"golang.org/x/exp/slices"
"tailscale.com/types/logid"
"tailscale.com/types/netlogtype"
"tailscale.com/util/cmpx"
"tailscale.com/util/must"
)
@@ -151,10 +152,10 @@ func printMessage(msg message) {
if len(traffic) == 0 {
return
}
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) bool {
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) int {
nx := x.TxPackets + x.TxBytes + x.RxPackets + x.RxBytes
ny := y.TxPackets + y.TxBytes + y.RxPackets + y.RxBytes
return nx > ny
return cmpx.Compare(ny, nx)
})
var sum netlogtype.Counts
for _, cc := range traffic {

View File

@@ -22,6 +22,7 @@ import (
"tailscale.com/net/netutil"
"tailscale.com/tsnet"
"tailscale.com/types/nettype"
"tailscale.com/util/clientmetric"
)
var (
@@ -32,6 +33,14 @@ var (
var tsMBox = dnsmessage.MustNewName("support.tailscale.com.")
var (
numSessions = clientmetric.NewCounter("sniproxy_sessions")
numBadAddrPort = clientmetric.NewCounter("sniproxy_bad_addrport")
dnsResponses = clientmetric.NewCounter("sniproxy_dns_responses")
dnsFailures = clientmetric.NewCounter("sniproxy_dns_failed")
httpPromoted = clientmetric.NewCounter("sniproxy_http_promoted")
)
func main() {
flag.Parse()
if *ports == "" {
@@ -109,6 +118,7 @@ func (s *server) serveDNSConn(c nettype.ConnPacketConn) {
n, err := c.Read(buf)
if err != nil {
log.Printf("c.Read failed: %v\n ", err)
dnsFailures.Add(1)
return
}
@@ -116,20 +126,25 @@ func (s *server) serveDNSConn(c nettype.ConnPacketConn) {
err = msg.Unpack(buf[:n])
if err != nil {
log.Printf("dnsmessage unpack failed: %v\n ", err)
dnsFailures.Add(1)
return
}
buf, err = s.dnsResponse(&msg)
if err != nil {
log.Printf("s.dnsResponse failed: %v\n", err)
dnsFailures.Add(1)
return
}
_, err = c.Write(buf)
if err != nil {
log.Printf("c.Write failed: %v\n", err)
dnsFailures.Add(1)
return
}
dnsResponses.Add(1)
}
func (s *server) serveConn(c net.Conn) {
@@ -137,6 +152,7 @@ func (s *server) serveConn(c net.Conn) {
_, port, err := net.SplitHostPort(addrPortStr)
if err != nil {
log.Printf("bogus addrPort %q", addrPortStr)
numBadAddrPort.Add(1)
c.Close()
return
}
@@ -149,6 +165,7 @@ func (s *server) serveConn(c net.Conn) {
return netutil.NewOneConnListener(c, nil), nil
}
p.AddSNIRouteFunc(addrPortStr, func(ctx context.Context, sniName string) (t tcpproxy.Target, ok bool) {
numSessions.Add(1)
return &tcpproxy.DialProxy{
Addr: net.JoinHostPort(sniName, port),
DialContext: dialer.DialContext,
@@ -218,6 +235,7 @@ func (s *server) dnsResponse(req *dnsmessage.Message) (buf []byte, err error) {
func (s *server) promoteHTTPS(ln net.Listener) {
err := http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpPromoted.Add(1)
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusFound)
}))
log.Fatalf("promoteHTTPS http.Serve: %v", err)

View File

@@ -6,33 +6,15 @@
package cli
import (
"unsafe"
"golang.org/x/sys/windows"
"tailscale.com/util/winutil/authenticode"
)
func init() {
verifyAuthenticode = verifyAuthenticodeWindows
verifyAuthenticode = verifyTailscale
}
func verifyAuthenticodeWindows(path string) error {
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
data := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_WHOLECHAIN, // Full revocation checking, as this is called with network connectivity.
UnionChoice: windows.WTD_CHOICE_FILE,
StateAction: windows.WTD_STATEACTION_VERIFY,
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(&windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: path16,
}),
}
err = windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
data.StateAction = windows.WTD_STATEACTION_CLOSE
windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
return err
const certSubjectTailscale = "Tailscale Inc."
func verifyTailscale(path string) error {
return authenticode.Verify(path, certSubjectTailscale)
}

View File

@@ -18,6 +18,7 @@ import (
"golang.org/x/exp/slices"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/cmpx"
)
var exitNodeCmd = &ffcli.Command{
@@ -227,19 +228,21 @@ func filterFormatAndSortExitNodes(peers []*ipnstate.PeerStatus, filterBy string)
// sortPeersByPriority sorts a slice of PeerStatus
// by location.Priority, in order of highest priority.
func sortPeersByPriority(peers []*ipnstate.PeerStatus) {
slices.SortFunc(peers, func(a, b *ipnstate.PeerStatus) bool { return a.Location.Priority > b.Location.Priority })
slices.SortStableFunc(peers, func(a, b *ipnstate.PeerStatus) int {
return cmpx.Compare(b.Location.Priority, a.Location.Priority)
})
}
// sortByCityName sorts a slice of filteredCity alphabetically
// by name. The '-' used to indicate no location data will always
// be sorted to the front of the slice.
func sortByCityName(cities []*filteredCity) {
slices.SortFunc(cities, func(a, b *filteredCity) bool { return a.Name < b.Name })
slices.SortStableFunc(cities, func(a, b *filteredCity) int { return strings.Compare(a.Name, b.Name) })
}
// sortByCountryName sorts a slice of filteredCountry alphabetically
// by name. The '-' used to indicate no location data will always
// be sorted to the front of the slice.
func sortByCountryName(countries []*filteredCountry) {
slices.SortFunc(countries, func(a, b *filteredCountry) bool { return a.Name < b.Name })
slices.SortStableFunc(countries, func(a, b *filteredCountry) int { return strings.Compare(a.Name, b.Name) })
}

View File

@@ -23,6 +23,7 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
)
var netlockCmd = &ffcli.Command{
@@ -40,6 +41,7 @@ var netlockCmd = &ffcli.Command{
nlDisablementKDFCmd,
nlLogCmd,
nlLocalDisableCmd,
nlRevokeKeysCmd,
},
Exec: runNetworkLockNoSubcommand,
}
@@ -711,3 +713,114 @@ func wrapAuthKey(ctx context.Context, keyStr string, status *ipnstate.Status) er
fmt.Println(wrapped)
return nil
}
var nlRevokeKeysArgs struct {
cosign bool
finish bool
forkFrom string
}
var nlRevokeKeysCmd = &ffcli.Command{
Name: "revoke-keys",
ShortUsage: "revoke-keys <tailnet-lock-key>...\n revoke-keys [--cosign] [--finish] <recovery-blob>",
ShortHelp: "Revoke compromised tailnet-lock keys",
LongHelp: `Retroactively revoke the specified tailnet lock keys (tlpub:abc).
Revoked keys are prevented from being used in the future. Any nodes previously signed
by revoked keys lose their authorization and must be signed again.
Revocation is a multi-step process that requires several signing nodes to ` + "`--cosign`" + ` the revocation. Use ` + "`tailscale lock remove`" + ` instead if the key has not been compromised.
1. To start, run ` + "`tailscale revoke-keys <tlpub-keys>`" + ` with the tailnet lock keys to revoke.
2. Re-run the ` + "`--cosign`" + ` command output by ` + "`revoke-keys`" + ` on other signing nodes. Use the
most recent command output on the next signing node in sequence.
3. Once the number of ` + "`--cosign`" + `s is greater than the number of keys being revoked,
run the command one final time with ` + "`--finish`" + ` instead of ` + "`--cosign`" + `.`,
Exec: runNetworkLockRevokeKeys,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock revoke-keys")
fs.BoolVar(&nlRevokeKeysArgs.cosign, "cosign", false, "continue generating the recovery using the tailnet lock key on this device and the provided recovery blob")
fs.BoolVar(&nlRevokeKeysArgs.finish, "finish", false, "finish the recovery process by transmitting the revocation")
fs.StringVar(&nlRevokeKeysArgs.forkFrom, "fork-from", "", "parent AUM hash to rewrite from (advanced users only)")
return fs
})(),
}
func runNetworkLockRevokeKeys(ctx context.Context, args []string) error {
// First step in the process
if !nlRevokeKeysArgs.cosign && !nlRevokeKeysArgs.finish {
removeKeys, _, err := parseNLArgs(args, true, false)
if err != nil {
return err
}
keyIDs := make([]tkatype.KeyID, len(removeKeys))
for i, k := range removeKeys {
keyIDs[i], err = k.ID()
if err != nil {
return fmt.Errorf("generating keyID: %v", err)
}
}
var forkFrom tka.AUMHash
if nlRevokeKeysArgs.forkFrom != "" {
if len(nlRevokeKeysArgs.forkFrom) == (len(forkFrom) * 2) {
// Hex-encoded: like the output of the lock log command.
b, err := hex.DecodeString(nlRevokeKeysArgs.forkFrom)
if err != nil {
return fmt.Errorf("invalid fork-from hash: %v", err)
}
copy(forkFrom[:], b)
} else {
if err := forkFrom.UnmarshalText([]byte(nlRevokeKeysArgs.forkFrom)); err != nil {
return fmt.Errorf("invalid fork-from hash: %v", err)
}
}
}
aumBytes, err := localClient.NetworkLockGenRecoveryAUM(ctx, keyIDs, forkFrom)
if err != nil {
return fmt.Errorf("generation of recovery AUM failed: %w", err)
}
fmt.Printf(`Run the following command on another machine with a trusted tailnet lock key:
%s lock recover-compromised-key --cosign %X
`, os.Args[0], aumBytes)
return nil
}
// If we got this far, we need to co-sign the AUM and/or transmit it for distribution.
b, err := hex.DecodeString(args[0])
if err != nil {
return fmt.Errorf("parsing hex: %v", err)
}
var recoveryAUM tka.AUM
if err := recoveryAUM.Unserialize(b); err != nil {
return fmt.Errorf("decoding recovery AUM: %v", err)
}
if nlRevokeKeysArgs.cosign {
aumBytes, err := localClient.NetworkLockCosignRecoveryAUM(ctx, recoveryAUM)
if err != nil {
return fmt.Errorf("co-signing recovery AUM failed: %w", err)
}
fmt.Printf(`Co-signing completed successfully.
To accumulate an additional signature, run the following command on another machine with a trusted tailnet lock key:
%s lock recover-compromised-key --cosign %X
Alternatively if you are done with co-signing, complete recovery by running the following command:
%s lock recover-compromised-key --finish %X
`, os.Args[0], aumBytes, os.Args[0], aumBytes)
}
if nlRevokeKeysArgs.finish {
if err := localClient.NetworkLockSubmitRecoveryAUM(ctx, recoveryAUM); err != nil {
return fmt.Errorf("submitting recovery AUM failed: %w", err)
}
fmt.Println("Recovery completed.")
}
return nil
}

View File

@@ -24,6 +24,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
"tailscale.com/version"
)
@@ -128,6 +129,7 @@ type localServeClient interface {
Status(context.Context) (*ipnstate.Status, error)
GetServeConfig(context.Context) (*ipn.ServeConfig, error)
SetServeConfig(context.Context, *ipn.ServeConfig) error
QueryFeature(context.Context, string) (*tailcfg.QueryFeatureResponse, error)
}
// serveEnv is the environment the serve command runs within. All I/O should be

View File

@@ -782,6 +782,10 @@ func (lc *fakeLocalServeClient) SetServeConfig(ctx context.Context, config *ipn.
return nil
}
func (lc *fakeLocalServeClient) QueryFeature(context.Context, string) (*tailcfg.QueryFeatureResponse, error) {
return nil, nil
}
// exactError returns an error checker that wants exactly the provided want error.
// If optName is non-empty, it's used in the error message.
func exactErr(want error, optName ...string) func(error) string {

View File

@@ -874,6 +874,10 @@ func requestedTailscaleVersion(ver, track string) (string, error) {
if ver != "" {
return ver, nil
}
return latestTailscaleVersion(track)
}
func latestTailscaleVersion(track string) (string, error) {
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/?mode=json&os=%s", track, runtime.GOOS)
res, err := http.Get(url)
if err != nil {

View File

@@ -23,14 +23,16 @@ var versionCmd = &ffcli.Command{
fs := newFlagSet("version")
fs.BoolVar(&versionArgs.daemon, "daemon", false, "also print local node's daemon version")
fs.BoolVar(&versionArgs.json, "json", false, "output in JSON format")
fs.BoolVar(&versionArgs.upstream, "upstream", false, "fetch and print the latest upstream release version from pkgs.tailscale.com")
return fs
})(),
Exec: runVersion,
}
var versionArgs struct {
daemon bool // also check local node's daemon version
json bool
daemon bool // also check local node's daemon version
json bool
upstream bool
}
func runVersion(ctx context.Context, args []string) error {
@@ -47,21 +49,46 @@ func runVersion(ctx context.Context, args []string) error {
}
}
var upstreamVer string
if versionArgs.upstream {
track := "stable"
if version.IsUnstableBuild() {
track = "unstable"
}
upstreamVer, err = latestTailscaleVersion(track)
if err != nil {
return err
}
}
if versionArgs.json {
m := version.GetMeta()
if st != nil {
m.DaemonLong = st.Version
}
out := struct {
version.Meta
Upstream string `json:"upstream,omitempty"`
}{
Meta: m,
Upstream: upstreamVer,
}
e := json.NewEncoder(os.Stdout)
e.SetIndent("", "\t")
return e.Encode(m)
return e.Encode(out)
}
if st == nil {
outln(version.String())
if versionArgs.upstream {
printf(" upstream: %s\n", upstreamVer)
}
return nil
}
printf("Client: %s\n", version.String())
printf("Daemon: %s\n", st.Version)
if versionArgs.upstream {
printf("Upstream: %s\n", upstreamVer)
}
return nil
}

View File

@@ -32,6 +32,7 @@ import (
"tailscale.com/util/cmpx"
"tailscale.com/util/groupmember"
"tailscale.com/version/distro"
"tailscale.com/webui"
)
//go:embed web.html
@@ -91,6 +92,7 @@ Tailscale, as opposed to a CLI or a native app.
webf := newFlagSet("web")
webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic")
webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script")
webf.BoolVar(&webArgs.dev, "dev", false, "run in dev mode")
return webf
})(),
Exec: runWeb,
@@ -99,6 +101,7 @@ Tailscale, as opposed to a CLI or a native app.
var webArgs struct {
listen string
cgi bool
dev bool
}
func tlsConfigFromEnvironment() *tls.Config {
@@ -129,8 +132,18 @@ func runWeb(ctx context.Context, args []string) error {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
handler := webHandler
if true {
newServer := &webui.Server{
DevMode: webArgs.dev,
}
cleanup := webui.RunJSDevServer()
defer cleanup()
handler = newServer.Handle
}
if webArgs.cgi {
if err := cgi.Serve(http.HandlerFunc(webHandler)); err != nil {
if err := cgi.Serve(http.HandlerFunc(handler)); err != nil {
log.Printf("tailscale.cgi: %v", err)
return err
}
@@ -142,14 +155,14 @@ func runWeb(ctx context.Context, args []string) error {
server := &http.Server{
Addr: webArgs.listen,
TLSConfig: tlsConfig,
Handler: http.HandlerFunc(webHandler),
Handler: http.HandlerFunc(handler),
}
log.Printf("web server running on: https://%s", server.Addr)
return server.ListenAndServeTLS("", "")
} else {
log.Printf("web server running on: %s", urlOfListenAddr(webArgs.listen))
return http.ListenAndServe(webArgs.listen, http.HandlerFunc(webHandler))
return http.ListenAndServe(webArgs.listen, http.HandlerFunc(handler))
}
}

View File

@@ -11,6 +11,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil/authenticode
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
L github.com/google/nftables from tailscale.com/util/linuxfw
@@ -52,7 +54,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/derp+
go4.org/netipx from tailscale.com/wgengine/filter
go4.org/netipx from tailscale.com/wgengine/filter+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
gopkg.in/yaml.v2 from sigs.k8s.io/yaml
k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli
@@ -66,7 +68,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
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/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
@@ -108,7 +110,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/tailcfg from tailscale.com/cmd/tailscale/cli+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/derp+
tailscale.com/tstime from tailscale.com/control/controlhttp+
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
tailscale.com/types/dnstype from tailscale.com/tailcfg
@@ -144,6 +146,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/net/dnscache+
💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/cmd/tailscale/cli
tailscale.com/version from tailscale.com/cmd/tailscale/cli+
tailscale.com/version/distro from tailscale.com/cmd/tailscale/cli+
tailscale.com/wgengine/capture from tailscale.com/cmd/tailscale/cli
@@ -194,7 +197,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
bytes from bufio+
compress/flate from compress/gzip+
compress/gzip from net/http
compress/zlib from image/png
compress/zlib from image/png+
container/list from crypto/tls+
context from crypto/tls+
crypto from crypto/ecdsa+
@@ -219,6 +222,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from tailscale.com/cmd/tailscale/cli+
encoding from encoding/json+
encoding/asn1 from crypto/x509+

View File

@@ -77,9 +77,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
LD 💣 github.com/creack/pty from tailscale.com/ssh/tailssh
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com+
W 💣 github.com/dblohm7/wingoes/com from tailscale.com/cmd/tailscaled
W github.com/dblohm7/wingoes/internal from github.com/dblohm7/wingoes/com
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/osdiag+
github.com/fxamacker/cbor/v2 from tailscale.com/tka
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
@@ -332,6 +333,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/mak from tailscale.com/control/controlclient+
tailscale.com/util/multierr from tailscale.com/control/controlclient+
tailscale.com/util/must from tailscale.com/logpolicy
💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+
W tailscale.com/util/pidowner from tailscale.com/ipn/ipnauth
tailscale.com/util/racebuild from tailscale.com/logpolicy
@@ -343,6 +345,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
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/control/controlclient+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/util/osdiag
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/hostinfo+
@@ -409,6 +412,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
bytes from bufio+
compress/flate from compress/gzip+
compress/gzip from golang.org/x/net/http2+
W compress/zlib from debug/pe
container/heap from gvisor.dev/gvisor/pkg/tcpip/transport/tcp
container/list from crypto/tls+
context from crypto/tls+
@@ -433,6 +437,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
crypto/tls from github.com/tcnksm/go-httpstat+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from tailscale.com+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
@@ -448,7 +454,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
flag from net/http/httptest+
fmt from compress/flate+
hash from crypto+
hash/adler32 from tailscale.com/ipn/ipnlocal
hash/adler32 from tailscale.com/ipn/ipnlocal+
hash/crc32 from compress/gzip+
hash/fnv from tailscale.com/wgengine/magicsock+
hash/maphash from go4.org/mem

View File

@@ -50,6 +50,7 @@ import (
"tailscale.com/tsd"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/util/osdiag"
"tailscale.com/util/winutil"
"tailscale.com/version"
"tailscale.com/wf"
@@ -127,7 +128,7 @@ var syslogf logger.Logf = logger.Discard
// Windows started.
func runWindowsService(pol *logpolicy.Policy) error {
go func() {
winutil.LogSupportInfo(log.Printf)
osdiag.LogSupportInfo(logger.WithPrefix(log.Printf, "Support Info: "), osdiag.LogSupportInfoReasonStartup)
}()
if winutil.GetPolicyInteger("LogSCMInteractions", 0) != 0 {

View File

@@ -15,6 +15,7 @@ import (
"tailscale.com/logtail/backoff"
"tailscale.com/net/sockstats"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/empty"
"tailscale.com/types/key"
"tailscale.com/types/logger"
@@ -48,7 +49,7 @@ var _ Client = (*Auto)(nil)
// It's a concrete implementation of the Client interface.
type Auto struct {
direct *Direct // our interface to the server APIs
timeNow func() time.Time
clock tstime.Clock
logf logger.Logf
expiry *time.Time
closed bool
@@ -107,12 +108,12 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
if opts.Logf == nil {
opts.Logf = func(fmt string, args ...any) {}
}
if opts.TimeNow == nil {
opts.TimeNow = time.Now
if opts.Clock == nil {
opts.Clock = tstime.StdClock{}
}
c := &Auto{
direct: direct,
timeNow: opts.TimeNow,
clock: opts.Clock,
logf: opts.Logf,
newMapCh: make(chan struct{}, 1),
quit: make(chan struct{}),
@@ -208,7 +209,7 @@ func (c *Auto) sendNewMapRequest() {
c.liteMapUpdateCancel = cancel
go func() {
defer cancel()
t0 := time.Now()
t0 := c.clock.Now()
err := c.direct.SendLiteMapUpdate(ctx)
d := time.Since(t0).Round(time.Millisecond)
@@ -704,14 +705,14 @@ func (c *Auto) Logout(ctx context.Context) error {
c.mu.Unlock()
c.cancelAuth()
timer := time.NewTimer(10 * time.Second)
timer, timerChannel := c.clock.NewTimer(10 * time.Second)
defer timer.Stop()
select {
case err := <-errc:
return err
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
case <-timerChannel:
return context.DeadlineExceeded
}
}
@@ -772,7 +773,7 @@ func (c *Auto) TestOnlySetAuthKey(authkey string) {
}
func (c *Auto) TestOnlyTimeNow() time.Time {
return c.timeNow()
return c.clock.Now()
}
// SetDNS sends the SetDNSRequest request to the control plane server,

View File

@@ -45,6 +45,7 @@ import (
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
@@ -63,7 +64,7 @@ type Direct struct {
dialer *tsdial.Dialer
dnsCache *dnscache.Resolver
serverURL string // URL of the tailcontrol server
timeNow func() time.Time
clock tstime.Clock
lastPrintMap time.Time
newDecompressor func() (Decompressor, error)
keepAlive bool
@@ -105,8 +106,8 @@ type Options struct {
GetMachinePrivateKey func() (key.MachinePrivate, error) // returns the machine key to use
ServerURL string // URL of the tailcontrol server
AuthKey string // optional node auth key for auto registration
TimeNow func() time.Time // time.Now implementation used by Client
Hostinfo *tailcfg.Hostinfo // non-nil passes ownership, nil means to use default using os.Hostname, etc
Clock tstime.Clock
Hostinfo *tailcfg.Hostinfo // non-nil passes ownership, nil means to use default using os.Hostname, etc
DiscoPublicKey key.DiscoPublic
NewDecompressor func() (Decompressor, error)
KeepAlive bool
@@ -191,8 +192,8 @@ func NewDirect(opts Options) (*Direct, error) {
if err != nil {
return nil, err
}
if opts.TimeNow == nil {
opts.TimeNow = time.Now
if opts.Clock == nil {
opts.Clock = tstime.StdClock{}
}
if opts.Logf == nil {
// TODO(apenwarr): remove this default and fail instead.
@@ -235,7 +236,7 @@ func NewDirect(opts Options) (*Direct, error) {
httpc: httpc,
getMachinePrivKey: opts.GetMachinePrivateKey,
serverURL: opts.ServerURL,
timeNow: opts.TimeNow,
clock: opts.Clock,
logf: opts.Logf,
newDecompressor: opts.NewDecompressor,
keepAlive: opts.KeepAlive,
@@ -432,7 +433,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
authKey, isWrapped, wrappedSig, wrappedKey := decodeWrappedAuthkey(c.authKey, c.logf)
hi := c.hostInfoLocked()
backendLogID := hi.BackendLogID
expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.timeNow())
expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.clock.Now())
c.mu.Unlock()
machinePrivKey, err := c.getMachinePrivKey()
@@ -537,7 +538,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
err = errors.New("hostinfo: BackendLogID missing")
return regen, opt.URL, nil, err
}
now := time.Now().Round(time.Second)
now := c.clock.Now().Round(time.Second)
request := tailcfg.RegisterRequest{
Version: 1,
OldNodeKey: oldNodeKey,
@@ -911,7 +912,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
defer cancel()
machinePubKey := machinePrivKey.Public()
t0 := time.Now()
t0 := c.clock.Now()
// Url and httpc are protocol specific.
var url string
@@ -954,7 +955,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
return nil
}
timeout := time.NewTimer(pollTimeout)
timeout, timeoutChannel := c.clock.NewTimer(pollTimeout)
timeoutReset := make(chan struct{})
pollDone := make(chan struct{})
defer close(pollDone)
@@ -964,14 +965,14 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
case <-pollDone:
vlogf("netmap: ending timeout goroutine")
return
case <-timeout.C:
case <-timeoutChannel:
c.logf("map response long-poll timed out!")
cancel()
return
case <-timeoutReset:
if !timeout.Stop() {
select {
case <-timeout.C:
case <-timeoutChannel:
case <-pollDone:
vlogf("netmap: ending timeout goroutine")
return
@@ -1096,7 +1097,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
go dumpGoroutinesToURL(c.httpc, resp.Debug.GoroutineDumpURL)
}
if sleep := time.Duration(resp.Debug.SleepSeconds * float64(time.Second)); sleep > 0 {
if err := sleepAsRequested(ctx, c.logf, timeoutReset, sleep); err != nil {
if err := sleepAsRequested(ctx, c.logf, timeoutReset, sleep, c.clock); err != nil {
return err
}
}
@@ -1126,7 +1127,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
// This is handy for debugging, and our logs processing
// pipeline depends on it. (TODO: Remove this dependency.)
// Code elsewhere prints netmap diffs every time they are received.
now := c.timeNow()
now := c.clock.Now()
if now.Sub(c.lastPrintMap) >= 5*time.Minute {
c.lastPrintMap = now
c.logf("[v1] new network map[%d]:\n%s", i, nm.VeryConcise())
@@ -1304,7 +1305,7 @@ func initDevKnob() devKnobs {
}
}
var clockNow = time.Now
var clock tstime.Clock = tstime.StdClock{}
// opt.Bool configs from control.
var (
@@ -1408,9 +1409,9 @@ func answerHeadPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest) {
if pr.Log {
logf("answerHeadPing: sending HEAD ping to %v ...", pr.URL)
}
t0 := time.Now()
t0 := clock.Now()
_, err = c.Do(req)
d := time.Since(t0).Round(time.Millisecond)
d := clock.Since(t0).Round(time.Millisecond)
if err != nil {
logf("answerHeadPing error: %v to %v (after %v)", err, pr.URL, d)
} else if pr.Log {
@@ -1456,7 +1457,7 @@ func answerC2NPing(logf logger.Logf, c2nHandler http.Handler, c *http.Client, pr
if pr.Log {
logf("answerC2NPing: sending POST ping to %v ...", pr.URL)
}
t0 := time.Now()
t0 := clock.Now()
_, err = c.Do(req)
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
@@ -1466,7 +1467,7 @@ func answerC2NPing(logf logger.Logf, c2nHandler http.Handler, c *http.Client, pr
}
}
func sleepAsRequested(ctx context.Context, logf logger.Logf, timeoutReset chan<- struct{}, d time.Duration) error {
func sleepAsRequested(ctx context.Context, logf logger.Logf, timeoutReset chan<- struct{}, d time.Duration, clock tstime.Clock) error {
const maxSleep = 5 * time.Minute
if d > maxSleep {
logf("sleeping for %v, capped from server-requested %v ...", maxSleep, d)
@@ -1475,20 +1476,20 @@ func sleepAsRequested(ctx context.Context, logf logger.Logf, timeoutReset chan<-
logf("sleeping for server-requested %v ...", d)
}
ticker := time.NewTicker(pollTimeout / 2)
ticker, tickerChannel := clock.NewTicker(pollTimeout / 2)
defer ticker.Stop()
timer := time.NewTimer(d)
timer, timerChannel := clock.NewTimer(d)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
case <-timerChannel:
return nil
case <-ticker.C:
case <-tickerChannel:
select {
case timeoutReset <- struct{}{}:
case <-timer.C:
case <-timerChannel:
return nil
case <-ctx.Done():
return ctx.Err()
@@ -1665,7 +1666,7 @@ func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pin
logf("invalid ping request: missing url, ip or pinger")
return
}
start := time.Now()
start := clock.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -1703,7 +1704,7 @@ func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailc
if pr.Log {
logf("postPingResult: sending ping results to %v ...", pr.URL)
}
t0 := time.Now()
t0 := clock.Now()
_, err = c.Do(req)
d := time.Since(t0).Round(time.Millisecond)
if err != nil {

View File

@@ -307,7 +307,7 @@ func undeltaPeers(mapRes *tailcfg.MapResponse, prev []*tailcfg.Node) {
for _, n := range newFull {
peerByID[n.ID] = n
}
now := clockNow()
now := clock.Now()
for nodeID, seen := range mapRes.PeerSeenChange {
if n, ok := peerByID[nodeID]; ok {
if seen {

View File

@@ -14,6 +14,7 @@ import (
"go4.org/mem"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/types/opt"
@@ -23,9 +24,6 @@ import (
func TestUndeltaPeers(t *testing.T) {
var curTime time.Time
tstest.Replace(t, &clockNow, func() time.Time {
return curTime
})
online := func(v bool) func(*tailcfg.Node) {
return func(n *tailcfg.Node) {
@@ -298,6 +296,7 @@ func TestUndeltaPeers(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
if !tt.curTime.IsZero() {
curTime = tt.curTime
tstest.Replace(t, &clock, tstime.Clock(tstest.NewClock(tstest.ClockOpts{Start: curTime})))
}
undeltaPeers(tt.mapRes, tt.prev)
if !reflect.DeepEqual(tt.mapRes.Peers, tt.want) {

View File

@@ -23,6 +23,7 @@ import (
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
@@ -450,6 +451,7 @@ func (nc *NoiseClient) dial(ctx context.Context) (*noiseConn, error) {
DialPlan: dialPlan,
Logf: nc.logf,
NetMon: nc.netMon,
Clock: tstime.StdClock{},
}).Dial(ctx)
if err != nil {
return nil, err

View File

@@ -127,7 +127,7 @@ func findIdentity(subject string, st certstore.Store) (certstore.Identity, []*x5
return nil, nil, err
}
selected, chain := selectIdentityFromSlice(subject, ids, time.Now())
selected, chain := selectIdentityFromSlice(subject, ids, clock.Now())
for _, id := range ids {
if id != selected {

View File

@@ -45,6 +45,7 @@ import (
"tailscale.com/net/tlsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/util/multierr"
)
@@ -147,13 +148,16 @@ func (a *Dialer) dial(ctx context.Context) (*ClientConn, error) {
// before we do anything.
if c.DialStartDelaySec > 0 {
a.logf("[v2] controlhttp: waiting %.2f seconds before dialing %q @ %v", c.DialStartDelaySec, a.Hostname, c.IP)
tmr := time.NewTimer(time.Duration(c.DialStartDelaySec * float64(time.Second)))
if a.Clock == nil {
a.Clock = tstime.StdClock{}
}
tmr, tmrChannel := a.Clock.NewTimer(time.Duration(c.DialStartDelaySec * float64(time.Second)))
defer tmr.Stop()
select {
case <-ctx.Done():
err = ctx.Err()
return
case <-tmr.C:
case <-tmrChannel:
}
}
@@ -319,7 +323,10 @@ func (a *Dialer) dialHost(ctx context.Context, addr netip.Addr) (*ClientConn, er
// 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) })
if a.Clock == nil {
a.Clock = tstime.StdClock{}
}
try443Timer := a.Clock.AfterFunc(a.httpsFallbackDelay(), func() { try(u443) })
defer try443Timer.Stop()
var err80, err443 error

View File

@@ -11,6 +11,7 @@ import (
"tailscale.com/net/dnscache"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@@ -89,6 +90,10 @@ type Dialer struct {
drainFinished chan struct{}
omitCertErrorLogging bool
testFallbackDelay time.Duration
// tstime.Clock is used instead of time package for methods such as time.Now.
// If not specified, will default to tstime.StdClock{}.
Clock tstime.Clock
}
func strDef(v1, v2 string) string {

View File

@@ -25,6 +25,7 @@ import (
"tailscale.com/net/socks5"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@@ -204,6 +205,7 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
Logf: t.Logf,
omitCertErrorLogging: true,
testFallbackDelay: 50 * time.Millisecond,
Clock: &tstest.Clock{},
}
if proxy != nil {
@@ -660,6 +662,7 @@ func TestDialPlan(t *testing.T) {
drainFinished: drained,
omitCertErrorLogging: true,
testFallbackDelay: 50 * time.Millisecond,
Clock: &tstest.Clock{},
}
conn, err := a.dial(ctx)

View File

@@ -56,6 +56,11 @@ type Client struct {
MeshKey string // optional; for trusted clients
IsProber bool // optional; for probers to optional declare themselves as such
// BaseContext, if non-nil, returns the base context to use for dialing a
// new derp server. If nil, context.Background is used.
// In either case, additional timeouts may be added to the base context.
BaseContext func() context.Context
privateKey key.NodePrivate
logf logger.Logf
netMon *netmon.Monitor // optional; nil means interfaces will be looked up on-demand
@@ -144,6 +149,19 @@ func (c *Client) Connect(ctx context.Context) error {
return err
}
// newContext returns a new context for setting up a new DERP connection.
// It uses either c.BaseContext or returns context.Background.
func (c *Client) newContext() context.Context {
if c.BaseContext != nil {
ctx := c.BaseContext()
if ctx == nil {
panic("BaseContext returned nil")
}
return ctx
}
return context.Background()
}
// TLSConnectionState returns the last TLS connection state, if any.
// The client must already be connected.
func (c *Client) TLSConnectionState() (_ *tls.ConnectionState, ok bool) {
@@ -776,7 +794,7 @@ func (c *Client) dialNodeUsingProxy(ctx context.Context, n *tailcfg.DERPNode, pr
}
func (c *Client) Send(dstKey key.NodePublic, b []byte) error {
client, _, err := c.connect(context.TODO(), "derphttp.Client.Send")
client, _, err := c.connect(c.newContext(), "derphttp.Client.Send")
if err != nil {
return err
}
@@ -876,7 +894,7 @@ func (c *Client) LocalAddr() (netip.AddrPort, error) {
}
func (c *Client) ForwardPacket(from, to key.NodePublic, b []byte) error {
client, _, err := c.connect(context.TODO(), "derphttp.Client.ForwardPacket")
client, _, err := c.connect(c.newContext(), "derphttp.Client.ForwardPacket")
if err != nil {
return err
}
@@ -942,7 +960,7 @@ func (c *Client) NotePreferred(v bool) {
//
// Only trusted connections (using MeshKey) are allowed to use this.
func (c *Client) WatchConnectionChanges() error {
client, _, err := c.connect(context.TODO(), "derphttp.Client.WatchConnectionChanges")
client, _, err := c.connect(c.newContext(), "derphttp.Client.WatchConnectionChanges")
if err != nil {
return err
}
@@ -957,7 +975,7 @@ func (c *Client) WatchConnectionChanges() error {
//
// Only trusted connections (using MeshKey) are allowed to use this.
func (c *Client) ClosePeer(target key.NodePublic) error {
client, _, err := c.connect(context.TODO(), "derphttp.Client.ClosePeer")
client, _, err := c.connect(c.newContext(), "derphttp.Client.ClosePeer")
if err != nil {
return err
}
@@ -978,7 +996,7 @@ func (c *Client) Recv() (derp.ReceivedMessage, error) {
// RecvDetail is like Recv, but additional returns the connection generation on each message.
// The connGen value is incremented every time the derphttp.Client reconnects to the server.
func (c *Client) RecvDetail() (m derp.ReceivedMessage, connGen int, err error) {
client, connGen, err := c.connect(context.TODO(), "derphttp.Client.Recv")
client, connGen, err := c.connect(c.newContext(), "derphttp.Client.Recv")
if err != nil {
return nil, 0, err
}

View File

@@ -115,4 +115,4 @@
in
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
}
# nix-direnv cache busting line: sha256-hWfdcvm2ief313JMgzDIispAnwi+D1iWsm0UHWOomxg=
# nix-direnv cache busting line: sha256-Fr4VZcKrXnT1PZuEG110KBefjcZzRsQRBSvByELKAy4=

44
go.mod
View File

@@ -18,7 +18,7 @@ require (
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/creack/pty v1.1.18
github.com/dave/jennifer v1.6.1
github.com/dblohm7/wingoes v0.0.0-20230426155039-111c8c3b57c8
github.com/dblohm7/wingoes v0.0.0-20230803162905-5c6286bb8c6e
github.com/dsnet/try v0.0.3
github.com/evanw/esbuild v0.14.53
github.com/frankban/quicktest v1.14.5
@@ -33,7 +33,7 @@ require (
github.com/google/go-containerregistry v0.14.0
github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c
github.com/google/uuid v1.3.0
github.com/goreleaser/nfpm v1.10.3
github.com/goreleaser/nfpm/v2 v2.32.1-0.20230803123630-24a43c5ad7cf
github.com/hdevalence/ed25519consensus v0.1.0
github.com/iancoleman/strcase v0.2.0
github.com/illarion/gonotify v1.0.1
@@ -41,7 +41,7 @@ require (
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86
github.com/jsimonetti/rtnetlink v1.3.2
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/klauspost/compress v1.16.5
github.com/klauspost/compress v1.16.7
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a
github.com/mattn/go-colorable v0.1.13
github.com/mattn/go-isatty v0.0.18
@@ -73,10 +73,10 @@ require (
github.com/vishvananda/netns v0.0.4
go.uber.org/zap v1.24.0
go4.org/mem v0.0.0-20220726221520-4f986261bf13
go4.org/netipx v0.0.0-20230303233057-f1b76eb4bb35
go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516
golang.org/x/crypto v0.11.0
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53
golang.org/x/mod v0.10.0
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090
golang.org/x/mod v0.11.0
golang.org/x/net v0.10.0
golang.org/x/oauth2 v0.7.0
golang.org/x/sync v0.2.0
@@ -103,8 +103,10 @@ require (
require (
4d63.com/gocheckcompilerdirectives v1.2.1 // indirect
4d63.com/gochecknoglobals v0.2.1 // indirect
dario.cat/mergo v1.0.0 // indirect
filippo.io/edwards25519 v1.0.0 // indirect
github.com/Abirdcfly/dupword v0.0.11 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/Antonboom/errname v0.1.9 // indirect
github.com/Antonboom/nilnil v0.1.4 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect
@@ -113,9 +115,9 @@ require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Masterminds/sprig v2.22.0+incompatible // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/OpenPeeDeeP/depguard v1.1.1 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230626094100-7e9e0395ebec // indirect
github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
@@ -170,9 +172,9 @@ require (
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/go-critic/go-critic v0.8.0 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.4.1 // indirect
github.com/go-git/go-git/v5 v5.6.1 // indirect
github.com/go-git/go-git/v5 v5.7.0 // indirect
github.com/go-logr/logr v1.2.4 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
@@ -202,10 +204,10 @@ require (
github.com/google/gnostic v0.6.9 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2 // indirect
github.com/google/rpmpack v0.0.0-20221120200012-98b63d62fd77 // indirect
github.com/google/rpmpack v0.5.0 // indirect
github.com/gordonklaus/ineffassign v0.0.0-20230107090616-13ace0543b28 // indirect
github.com/goreleaser/chglog v0.4.2 // indirect
github.com/goreleaser/fileglob v0.3.1 // indirect
github.com/goreleaser/chglog v0.5.0 // indirect
github.com/goreleaser/fileglob v1.3.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.4.2 // indirect
github.com/gostaticanalysis/forcetypeassert v0.1.0 // indirect
@@ -216,7 +218,7 @@ require (
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
github.com/imdario/mergo v0.3.15 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jgautheron/goconst v1.5.1 // indirect
@@ -231,7 +233,7 @@ require (
github.com/kisielk/errcheck v1.6.3 // indirect
github.com/kisielk/gotool v1.0.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.4 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
@@ -269,7 +271,7 @@ require (
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0-rc3 // indirect
github.com/pelletier/go-toml/v2 v2.0.7 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e // indirect
@@ -288,27 +290,27 @@ require (
github.com/sanposhiho/wastedassign/v2 v2.0.7 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.23.0 // indirect
github.com/sassoftware/go-rpmutils v0.2.0 // indirect
github.com/securego/gosec/v2 v2.15.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/nosnakecase v1.7.0 // indirect
github.com/sivchari/tenv v1.7.1 // indirect
github.com/skeema/knownhosts v1.1.0 // indirect
github.com/skeema/knownhosts v1.1.1 // indirect
github.com/sonatard/noctx v0.0.2 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/cobra v1.7.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.15.0 // indirect
github.com/spf13/viper v1.16.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/testify v1.8.2 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c // indirect
github.com/tdakkota/asciicheck v0.2.0 // indirect

View File

@@ -1 +1 @@
sha256-hWfdcvm2ief313JMgzDIispAnwi+D1iWsm0UHWOomxg=
sha256-Fr4VZcKrXnT1PZuEG110KBefjcZzRsQRBSvByELKAy4=

440
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
a96a9eddc031c85f22378ef1e37e3fd7e9c482ef
d149af282305d5365d5a4fb576d9fa81247eb6da

View File

@@ -339,11 +339,11 @@ func (s certStateStore) Read(domain string, now time.Time) (*TLSCertKeyPair, err
}
func (s certStateStore) WriteCert(domain string, cert []byte) error {
return s.WriteState(ipn.StateKey(domain+".crt"), cert)
return ipn.WriteState(s.StateStore, ipn.StateKey(domain+".crt"), cert)
}
func (s certStateStore) WriteKey(domain string, key []byte) error {
return s.WriteState(ipn.StateKey(domain+".key"), key)
return ipn.WriteState(s.StateStore, ipn.StateKey(domain+".key"), key)
}
func (s certStateStore) ACMEKey() ([]byte, error) {
@@ -351,7 +351,7 @@ func (s certStateStore) ACMEKey() ([]byte, error) {
}
func (s certStateStore) WriteACMEKey(key []byte) error {
return s.WriteState(ipn.StateKey(acmePEMName), key)
return ipn.WriteState(s.StateStore, ipn.StateKey(acmePEMName), key)
}
// TLSCertKeyPair is a TLS public and private key, and whether they were obtained

View File

@@ -2209,7 +2209,7 @@ func (b *LocalBackend) initMachineKeyLocked() (err error) {
}
keyText, _ = b.machinePrivKey.MarshalText()
if err := b.store.WriteState(ipn.MachineKeyStateKey, keyText); err != nil {
if err := ipn.WriteState(b.store, ipn.MachineKeyStateKey, keyText); err != nil {
b.logf("error writing machine key to store: %v", err)
return err
}
@@ -2224,7 +2224,7 @@ func (b *LocalBackend) initMachineKeyLocked() (err error) {
//
// b.mu must be held.
func (b *LocalBackend) clearMachineKeyLocked() error {
if err := b.store.WriteState(ipn.MachineKeyStateKey, nil); err != nil {
if err := ipn.WriteState(b.store, ipn.MachineKeyStateKey, nil); err != nil {
return err
}
b.machinePrivKey = key.MachinePrivate{}
@@ -4463,7 +4463,7 @@ func (b *LocalBackend) CheckIPForwarding() error {
}
// TODO: let the caller pass in the ranges.
warn, err := netutil.CheckIPForwarding(tsaddr.ExitRoutes(), nil)
warn, err := netutil.CheckIPForwarding(tsaddr.ExitRoutes(), b.sys.NetMon.Get().InterfaceState())
if err != nil {
return err
}
@@ -4830,7 +4830,7 @@ func (b *LocalBackend) SetDevStateStore(key, value string) error {
if b.store == nil {
return errors.New("no state store")
}
err := b.store.WriteState(ipn.StateKey(key), []byte(value))
err := ipn.WriteState(b.store, ipn.StateKey(key), []byte(value))
b.logf("SetDevStateStore(%q, %q) = %v", key, value, err)
if err != nil {

View File

@@ -845,6 +845,93 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
return resp.Signatures, nil
}
// NetworkLockGenerateRecoveryAUM generates an AUM which retroactively removes trust in the
// specified keys. This AUM is signed by the current node and returned.
//
// If forkFrom is specified, it is used as the parent AUM to fork from. If the zero value,
// the parent AUM is determined automatically.
func (b *LocalBackend) NetworkLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
nlPriv = p.Persist().NetworkLockKey()
}
if nlPriv.IsZero() {
return nil, errMissingNetmap
}
aum, err := b.tka.authority.MakeRetroactiveRevocation(b.tka.storage, removeKeys, nlPriv.KeyID(), forkFrom)
if err != nil {
return nil, err
}
// Sign it ourselves.
aum.Signatures, err = nlPriv.SignAUM(aum.SigHash())
if err != nil {
return nil, fmt.Errorf("signing failed: %w", err)
}
return aum, nil
}
// NetworkLockCosignRecoveryAUM co-signs the provided recovery AUM and returns
// the updated structure.
//
// The recovery AUM provided should be the output from a previous call to
// NetworkLockGenerateRecoveryAUM or NetworkLockCosignRecoveryAUM.
func (b *LocalBackend) NetworkLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
nlPriv = p.Persist().NetworkLockKey()
}
if nlPriv.IsZero() {
return nil, errMissingNetmap
}
for _, sig := range aum.Signatures {
if bytes.Equal(sig.KeyID, nlPriv.KeyID()) {
return nil, errors.New("this node has already signed this recovery AUM")
}
}
// Sign it ourselves.
sigs, err := nlPriv.SignAUM(aum.SigHash())
if err != nil {
return nil, fmt.Errorf("signing failed: %w", err)
}
aum.Signatures = append(aum.Signatures, sigs...)
return aum, nil
}
func (b *LocalBackend) NetworkLockSubmitRecoveryAUM(aum *tka.AUM) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return errNetworkLockNotActive
}
var ourNodeKey key.NodePublic
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() && !p.Persist().PrivateNodeKey().IsZero() {
ourNodeKey = p.Persist().PublicNodeKey()
}
if ourNodeKey.IsZero() {
return errors.New("no node-key: is tailscale logged in?")
}
b.mu.Unlock()
_, err := b.tkaDoSyncSend(ourNodeKey, aum.Hash(), []tka.AUM{*aum}, false)
b.mu.Lock()
return err
}
var tkaSuffixEncoder = base64.RawStdEncoding
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to

View File

@@ -994,3 +994,129 @@ func TestTKAAffectedSigs(t *testing.T) {
})
}
}
func TestTKARecoverCompromisedKeyFlow(t *testing.T) {
nodePriv := key.NewNode()
nlPriv := key.NewNLPrivate()
cosignPriv := key.NewNLPrivate()
compromisedPriv := key.NewNLPrivate()
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
must.Do(pm.SetPrefs((&ipn.Prefs{
Persist: &persist.Persist{
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View()))
// Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
cosignKey := tka.Key{Kind: tka.Key25519, Public: cosignPriv.Public().Verifier(), Votes: 2}
compromisedKey := tka.Key{Kind: tka.Key25519, Public: compromisedPriv.Public().Verifier(), Votes: 1}
temp := t.TempDir()
tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID))
os.Mkdir(tkaPath, 0755)
chonk, err := tka.ChonkDir(tkaPath)
if err != nil {
t.Fatal(err)
}
authority, _, err := tka.Create(chonk, tka.State{
Keys: []tka.Key{key, compromisedKey, cosignKey},
DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)},
}, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/sync/send":
body := new(tailcfg.TKASyncSendRequest)
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
t.Fatal(err)
}
t.Logf("got sync send:\n%+v", body)
var remoteHead tka.AUMHash
if err := remoteHead.UnmarshalText([]byte(body.Head)); err != nil {
t.Fatalf("head unmarshal: %v", err)
}
toApply := make([]tka.AUM, len(body.MissingAUMs))
for i, a := range body.MissingAUMs {
if err := toApply[i].Unserialize(a); err != nil {
t.Fatalf("decoding missingAUM[%d]: %v", i, err)
}
}
// Apply the recovery AUM to an authority to make sure it works.
if err := authority.Inform(chonk, toApply); err != nil {
t.Errorf("recovery AUM could not be applied: %v", err)
}
// Make sure the key we removed isn't trusted.
if authority.KeyTrusted(compromisedPriv.KeyID()) {
t.Error("compromised key was not removed from tka")
}
w.WriteHeader(200)
if err := json.NewEncoder(w).Encode(tailcfg.TKASubmitSignatureResponse{}); err != nil {
t.Fatal(err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
cc := fakeControlClient(t, client)
b := LocalBackend{
varRoot: temp,
cc: cc,
ccAuto: cc,
logf: t.Logf,
tka: &tkaState{
authority: authority,
storage: chonk,
},
pm: pm,
store: pm.Store(),
}
aum, err := b.NetworkLockGenerateRecoveryAUM([]tkatype.KeyID{compromisedPriv.KeyID()}, tka.AUMHash{})
if err != nil {
t.Fatalf("NetworkLockGenerateRecoveryAUM() failed: %v", err)
}
// Cosign using the cosigning key.
{
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
must.Do(pm.SetPrefs((&ipn.Prefs{
Persist: &persist.Persist{
PrivateNodeKey: nodePriv,
NetworkLockKey: cosignPriv,
},
}).View()))
b := LocalBackend{
varRoot: temp,
logf: t.Logf,
tka: &tkaState{
authority: authority,
storage: chonk,
},
pm: pm,
store: pm.Store(),
}
if aum, err = b.NetworkLockCosignRecoveryAUM(aum); err != nil {
t.Fatalf("NetworkLockCosignRecoveryAUM() failed: %v", err)
}
}
// Finally, submit the recovery AUM. Validation is done
// in the fake control handler.
if err := b.NetworkLockSubmitRecoveryAUM(aum); err != nil {
t.Errorf("NetworkLockSubmitRecoveryAUM() failed: %v", err)
}
}

View File

@@ -902,8 +902,8 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
for label := range stats.Stats {
labels = append(labels, label)
}
slices.SortFunc(labels, func(a, b sockstats.Label) bool {
return a.String() < b.String()
slices.SortFunc(labels, func(a, b sockstats.Label) int {
return strings.Compare(a.String(), b.String())
})
txTotal := uint64(0)
@@ -1282,8 +1282,8 @@ func (h *peerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request)
return
}
var password []byte // TODO(bradfitz): support?
st, err := interfaces.GetState()
if err != nil {
st := h.ps.b.sys.NetMon.Get().InterfaceState()
if st == nil {
http.Error(w, "failed to get interfaces state", http.StatusInternalServerError)
return
}

View File

@@ -51,6 +51,10 @@ func (pm *profileManager) dlogf(format string, args ...any) {
pm.logf(format, args...)
}
func (pm *profileManager) WriteState(id ipn.StateKey, val []byte) error {
return ipn.WriteState(pm.store, id, val)
}
// CurrentUserID returns the current user ID. It is only non-empty on
// Windows where we have a multi-user system.
func (pm *profileManager) CurrentUserID() ipn.WindowsUserID {
@@ -182,9 +186,9 @@ func (pm *profileManager) setUnattendedModeAsConfigured() error {
}
if pm.prefs.ForceDaemon() {
return pm.store.WriteState(ipn.ServerModeStartKey, []byte(pm.currentProfile.Key))
return pm.WriteState(ipn.ServerModeStartKey, []byte(pm.currentProfile.Key))
} else {
return pm.store.WriteState(ipn.ServerModeStartKey, nil)
return pm.WriteState(ipn.ServerModeStartKey, nil)
}
}
@@ -288,7 +292,7 @@ func (pm *profileManager) writePrefsToStore(key ipn.StateKey, prefs ipn.PrefsVie
if key == "" {
return nil
}
if err := pm.store.WriteState(key, prefs.ToBytes()); err != nil {
if err := pm.WriteState(key, prefs.ToBytes()); err != nil {
pm.logf("WriteState(%q): %v", key, err)
return err
}
@@ -298,8 +302,8 @@ func (pm *profileManager) writePrefsToStore(key ipn.StateKey, prefs ipn.PrefsVie
// Profiles returns the list of known profiles.
func (pm *profileManager) Profiles() []ipn.LoginProfile {
profiles := pm.matchingProfiles(func(*ipn.LoginProfile) bool { return true })
slices.SortFunc(profiles, func(a, b *ipn.LoginProfile) bool {
return a.Name < b.Name
slices.SortFunc(profiles, func(a, b *ipn.LoginProfile) int {
return strings.Compare(a.Name, b.Name)
})
out := make([]ipn.LoginProfile, 0, len(profiles))
for _, p := range profiles {
@@ -336,7 +340,7 @@ func (pm *profileManager) SwitchProfile(id ipn.ProfileID) error {
func (pm *profileManager) setAsUserSelectedProfileLocked() error {
k := ipn.CurrentProfileKey(string(pm.currentUserID))
return pm.store.WriteState(k, []byte(pm.currentProfile.Key))
return pm.WriteState(k, []byte(pm.currentProfile.Key))
}
func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error) {
@@ -394,7 +398,7 @@ func (pm *profileManager) DeleteProfile(id ipn.ProfileID) error {
if kp.ID == pm.currentProfile.ID {
pm.NewProfile()
}
if err := pm.store.WriteState(kp.Key, nil); err != nil {
if err := pm.WriteState(kp.Key, nil); err != nil {
return err
}
delete(pm.knownProfiles, id)
@@ -407,7 +411,7 @@ func (pm *profileManager) DeleteAllProfiles() error {
metricDeleteAllProfile.Add(1)
for _, kp := range pm.knownProfiles {
if err := pm.store.WriteState(kp.Key, nil); err != nil {
if err := pm.WriteState(kp.Key, nil); err != nil {
// Write to remove references to profiles we've already deleted, but
// return the original error.
pm.writeKnownProfiles()
@@ -424,7 +428,7 @@ func (pm *profileManager) writeKnownProfiles() error {
if err != nil {
return err
}
return pm.store.WriteState(ipn.KnownProfilesStateKey, b)
return pm.WriteState(ipn.KnownProfilesStateKey, b)
}
// NewProfile creates and switches to a new unnamed profile. The new profile is

View File

@@ -499,6 +499,7 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) {
// Clear any incoming values squatting in the headers.
r.Out.Header.Del("Tailscale-User-Login")
r.Out.Header.Del("Tailscale-User-Name")
r.Out.Header.Del("Tailscale-User-Profile-Pic")
r.Out.Header.Del("Tailscale-Headers-Info")
c, ok := getServeHTTPContext(r.Out)
@@ -516,6 +517,7 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) {
}
r.Out.Header.Set("Tailscale-User-Login", user.LoginName)
r.Out.Header.Set("Tailscale-User-Name", user.DisplayName)
r.Out.Header.Set("Tailscale-User-Profile-Pic", user.ProfilePicURL)
r.Out.Header.Set("Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers")
}

View File

@@ -195,8 +195,9 @@ func TestServeHTTPProxy(t *testing.T) {
},
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
tailcfg.UserID(1): {
LoginName: "someone@example.com",
DisplayName: "Some One",
LoginName: "someone@example.com",
DisplayName: "Some One",
ProfilePicURL: "https://example.com/photo.jpg",
},
},
}
@@ -253,6 +254,7 @@ func TestServeHTTPProxy(t *testing.T) {
{"X-Forwarded-For", "100.150.151.152"},
{"Tailscale-User-Login", "someone@example.com"},
{"Tailscale-User-Name", "Some One"},
{"Tailscale-User-Profile-Pic", "https://example.com/photo.jpg"},
{"Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers"},
},
},
@@ -264,6 +266,7 @@ func TestServeHTTPProxy(t *testing.T) {
{"X-Forwarded-For", "100.150.151.153"},
{"Tailscale-User-Login", ""},
{"Tailscale-User-Name", ""},
{"Tailscale-User-Profile-Pic", ""},
{"Tailscale-Headers-Info", ""},
},
},
@@ -275,6 +278,7 @@ func TestServeHTTPProxy(t *testing.T) {
{"X-Forwarded-For", "100.160.161.162"},
{"Tailscale-User-Login", ""},
{"Tailscale-User-Name", ""},
{"Tailscale-User-Profile-Pic", ""},
{"Tailscale-Headers-Info", ""},
},
},

View File

@@ -44,9 +44,11 @@ import (
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/ptr"
"tailscale.com/types/tkatype"
"tailscale.com/util/clientmetric"
"tailscale.com/util/httpm"
"tailscale.com/util/mak"
"tailscale.com/util/osdiag"
"tailscale.com/version"
)
@@ -106,9 +108,13 @@ var handler = map[string]localAPIHandler{
"tka/affected-sigs": (*Handler).serveTKAAffectedSigs,
"tka/wrap-preauth-key": (*Handler).serveTKAWrapPreauthKey,
"tka/verify-deeplink": (*Handler).serveTKAVerifySigningDeeplink,
"tka/generate-recovery-aum": (*Handler).serveTKAGenerateRecoveryAUM,
"tka/cosign-recovery-aum": (*Handler).serveTKACosignRecoveryAUM,
"tka/submit-recovery-aum": (*Handler).serveTKASubmitRecoveryAUM,
"upload-client-metrics": (*Handler).serveUploadClientMetrics,
"watch-ipn-bus": (*Handler).serveWatchIPNBus,
"whois": (*Handler).serveWhoIs,
"query-feature": (*Handler).serveQueryFeature,
}
func randHex(n int) string {
@@ -345,6 +351,9 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
// logs for them.
envknob.LogCurrent(logger.WithPrefix(h.logf, "user bugreport: "))
// OS-specific details
osdiag.LogSupportInfo(logger.WithPrefix(h.logf, "user bugreport OS: "), osdiag.LogSupportInfoReasonBugReport)
if defBool(r.URL.Query().Get("diagnose"), false) {
h.b.Doctor(r.Context(), logger.WithPrefix(h.logf, "diag: "))
}
@@ -427,8 +436,8 @@ func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) {
return
}
res := &apitype.WhoIsResponse{
Node: n,
UserProfile: &u,
Node: n, // always non-nil per WhoIsResponse contract
UserProfile: &u, // always non-nil per WhoIsResponse contract
CapMap: b.PeerCaps(ipp.Addr()),
}
j, err := json.MarshalIndent(res, "", "\t")
@@ -1747,6 +1756,103 @@ func (h *Handler) serveTKAAffectedSigs(w http.ResponseWriter, r *http.Request) {
w.Write(j)
}
func (h *Handler) serveTKAGenerateRecoveryAUM(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
type verifyRequest struct {
Keys []tkatype.KeyID
ForkFrom string
}
var req verifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON for verifyRequest body", http.StatusBadRequest)
return
}
var forkFrom tka.AUMHash
if req.ForkFrom != "" {
if err := forkFrom.UnmarshalText([]byte(req.ForkFrom)); err != nil {
http.Error(w, "decoding fork-from: "+err.Error(), http.StatusBadRequest)
return
}
}
res, err := h.b.NetworkLockGenerateRecoveryAUM(req.Keys, forkFrom)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(res.Serialize())
}
func (h *Handler) serveTKACosignRecoveryAUM(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
body := io.LimitReader(r.Body, 1024*1024)
aumBytes, err := ioutil.ReadAll(body)
if err != nil {
http.Error(w, "reading AUM", http.StatusBadRequest)
return
}
var aum tka.AUM
if err := aum.Unserialize(aumBytes); err != nil {
http.Error(w, "decoding AUM", http.StatusBadRequest)
return
}
res, err := h.b.NetworkLockCosignRecoveryAUM(&aum)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(res.Serialize())
}
func (h *Handler) serveTKASubmitRecoveryAUM(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
body := io.LimitReader(r.Body, 1024*1024)
aumBytes, err := ioutil.ReadAll(body)
if err != nil {
http.Error(w, "reading AUM", http.StatusBadRequest)
return
}
var aum tka.AUM
if err := aum.Unserialize(aumBytes); err != nil {
http.Error(w, "decoding AUM", http.StatusBadRequest)
return
}
if err := h.b.NetworkLockSubmitRecoveryAUM(&aum); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// serveProfiles serves profile switching-related endpoints. Supported methods
// and paths are:
// - GET /profiles/: list all profiles (JSON-encoded array of ipn.LoginProfiles)
@@ -1831,6 +1937,66 @@ func (h *Handler) serveProfiles(w http.ResponseWriter, r *http.Request) {
}
}
// serveQueryFeature makes a request to the "/machine/feature/query"
// Noise endpoint to get instructions on how to enable a feature, such as
// Funnel, for the node's tailnet.
//
// This request itself does not directly enable the feature on behalf of
// the node, but rather returns information that can be presented to the
// acting user about where/how to enable the feature. If relevant, this
// includes a control URL the user can visit to explicitly consent to
// using the feature.
//
// See tailcfg.QueryFeatureResponse for full response structure.
func (h *Handler) serveQueryFeature(w http.ResponseWriter, r *http.Request) {
feature := r.FormValue("feature")
switch {
case !h.PermitRead:
http.Error(w, "access denied", http.StatusForbidden)
return
case r.Method != httpm.POST:
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
case feature == "":
http.Error(w, "missing feature", http.StatusInternalServerError)
return
}
nm := h.b.NetMap()
if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable)
return
}
b, err := json.Marshal(&tailcfg.QueryFeatureRequest{
NodeKey: nm.NodeKey,
Feature: feature,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
req, err := http.NewRequestWithContext(r.Context(),
"POST", "https://unused/machine/feature/query", bytes.NewReader(b))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp, err := h.b.DoNoiseRequest(req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func defBool(a string, def bool) bool {
if a == "" {
return def

View File

@@ -4,6 +4,7 @@
package ipn
import (
"bytes"
"context"
"errors"
"fmt"
@@ -71,9 +72,22 @@ type StateStore interface {
// ErrStateNotExist) if the ID doesn't have associated state.
ReadState(id StateKey) ([]byte, error)
// WriteState saves bs as the state associated with ID.
//
// Callers should generally use the ipn.WriteState wrapper func
// instead, which only writes if the value is different from what's
// already in the store.
WriteState(id StateKey, bs []byte) error
}
// WriteState is a wrapper around store.WriteState that only writes if
// the value is different from what's already in the store.
func WriteState(store StateStore, id StateKey, v []byte) error {
if was, err := store.ReadState(id); err == nil && bytes.Equal(was, v) {
return nil
}
return store.WriteState(id, v)
}
// StateStoreDialerSetter is an optional interface that StateStores
// can implement to allow the caller to set a custom dialer.
type StateStoreDialerSetter interface {
@@ -91,5 +105,5 @@ func ReadStoreInt(store StateStore, id StateKey) (int64, error) {
// PutStoreInt puts an integer into a StateStore.
func PutStoreInt(store StateStore, id StateKey, val int64) error {
return store.WriteState(id, fmt.Appendf(nil, "%d", val))
return WriteState(store, id, fmt.Appendf(nil, "%d", val))
}

48
ipn/store_test.go Normal file
View File

@@ -0,0 +1,48 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipn
import (
"bytes"
"sync"
"testing"
"tailscale.com/util/mak"
)
type memStore struct {
mu sync.Mutex
writes int
m map[StateKey][]byte
}
func (s *memStore) ReadState(k StateKey) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return bytes.Clone(s.m[k]), nil
}
func (s *memStore) WriteState(k StateKey, v []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
mak.Set(&s.m, k, bytes.Clone(v))
s.writes++
return nil
}
func TestWriteState(t *testing.T) {
var ss StateStore = new(memStore)
WriteState(ss, "foo", []byte("bar"))
WriteState(ss, "foo", []byte("bar"))
got, err := ss.ReadState("foo")
if err != nil {
t.Fatal(err)
}
if want := []byte("bar"); !bytes.Equal(got, want) {
t.Errorf("got %q; want %q", got, want)
}
if got, want := ss.(*memStore).writes, 1; got != want {
t.Errorf("got %d writes; want %d", got, want)
}
}

View File

@@ -38,28 +38,30 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/v5.1.0/LICENSE))
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/9aa6fdf5a28c/LICENSE))
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.1.0/LICENSE))
- [github.com/illarion/gonotify](https://pkg.go.dev/github.com/illarion/gonotify) ([MIT](https://github.com/illarion/gonotify/blob/v1.0.1/LICENSE))
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/974c6f05fe16/LICENSE))
- [github.com/jmespath/go-jmespath](https://pkg.go.dev/github.com/jmespath/go-jmespath) ([Apache-2.0](https://github.com/jmespath/go-jmespath/blob/v0.4.0/LICENSE))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.3.2/LICENSE.md))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.16.5/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.16.5/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.16.5/zstd/internal/xxhash/LICENSE.txt))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.16.7/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.16.7/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.16.7/zstd/internal/xxhash/LICENSE.txt))
- [github.com/kortschak/wol](https://pkg.go.dev/github.com/kortschak/wol) ([BSD-3-Clause](https://github.com/kortschak/wol/blob/da482cc4850a/LICENSE))
- [github.com/mdlayher/genetlink](https://pkg.go.dev/github.com/mdlayher/genetlink) ([MIT](https://github.com/mdlayher/genetlink/blob/v1.3.2/LICENSE.md))
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.7.2/LICENSE.md))
- [github.com/mdlayher/sdnotify](https://pkg.go.dev/github.com/mdlayher/sdnotify) ([MIT](https://github.com/mdlayher/sdnotify/blob/v1.0.0/LICENSE.md))
- [github.com/mdlayher/socket](https://pkg.go.dev/github.com/mdlayher/socket) ([MIT](https://github.com/mdlayher/socket/blob/v0.4.1/LICENSE.md))
- [github.com/miekg/dns](https://pkg.go.dev/github.com/miekg/dns) ([BSD-3-Clause](https://github.com/miekg/dns/blob/v1.1.55/LICENSE))
- [github.com/mitchellh/go-ps](https://pkg.go.dev/github.com/mitchellh/go-ps) ([MIT](https://github.com/mitchellh/go-ps/blob/v1.0.0/LICENSE.md))
- [github.com/pierrec/lz4/v4](https://pkg.go.dev/github.com/pierrec/lz4/v4) ([BSD-3-Clause](https://github.com/pierrec/lz4/blob/v4.1.17/LICENSE))
- [github.com/skip2/go-qrcode](https://pkg.go.dev/github.com/skip2/go-qrcode) ([MIT](https://github.com/skip2/go-qrcode/blob/da1b6568686e/LICENSE))
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/17a3db2c30d2/LICENSE))
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/f0b76a10a08e/LICENSE))
- [github.com/tailscale/goupnp](https://pkg.go.dev/github.com/tailscale/goupnp) ([BSD-2-Clause](https://github.com/tailscale/goupnp/blob/c64d0f06ea05/LICENSE))
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
- [github.com/tailscale/tailscale-android](https://pkg.go.dev/github.com/tailscale/tailscale-android) ([BSD-3-Clause](https://github.com/tailscale/tailscale-android/blob/HEAD/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/af172621b4dd/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/bb2c8f22eccf/LICENSE))
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
- [github.com/u-root/uio](https://pkg.go.dev/github.com/u-root/uio) ([BSD-3-Clause](https://github.com/u-root/uio/blob/3e8cd9d6bf63/LICENSE))
- [github.com/vishvananda/netlink/nl](https://pkg.go.dev/github.com/vishvananda/netlink/nl) ([Apache-2.0](https://github.com/vishvananda/netlink/blob/v1.2.1-beta.2/LICENSE))
@@ -67,17 +69,17 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
- [go4.org/intern](https://pkg.go.dev/go4.org/intern) ([BSD-3-Clause](https://github.com/go4org/intern/blob/ae77deb06f29/LICENSE))
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/4f986261bf13/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/f1b76eb4bb35/LICENSE))
- [go4.org/unsafe/assume-no-moving-gc](https://pkg.go.dev/go4.org/unsafe/assume-no-moving-gc) ([BSD-3-Clause](https://github.com/go4org/unsafe-assume-no-moving-gc/blob/ee73d164e760/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.9.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/47ecfdc1:LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/ad4cb58a6516/LICENSE))
- [go4.org/unsafe/assume-no-moving-gc](https://pkg.go.dev/go4.org/unsafe/assume-no-moving-gc) ([BSD-3-Clause](https://github.com/go4org/unsafe-assume-no-moving-gc/blob/e7c30c78aeb2/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.11.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/515e97eb:LICENSE))
- [golang.org/x/exp/shiny](https://pkg.go.dev/golang.org/x/exp/shiny) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/334a2380:shiny/LICENSE))
- [golang.org/x/image](https://pkg.go.dev/golang.org/x/image) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.7.0:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.10.0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.2.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/5059a07a:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.8.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.9.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.10.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.10.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.11.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.3.0:LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/7b0a1988a28f/LICENSE))
- [inet.af/netaddr](https://pkg.go.dev/inet.af/netaddr) ([BSD-3-Clause](https://github.com/inetaf/netaddr/blob/097006376321/LICENSE))

View File

@@ -35,7 +35,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.13.5/internal/sync/singleflight/LICENSE))
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/v0.6.0/LICENSE))
- [github.com/creack/pty](https://pkg.go.dev/github.com/creack/pty) ([MIT](https://github.com/creack/pty/blob/v1.1.18/LICENSE))
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/111c8c3b57c8/LICENSE))
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/5c6286bb8c6e/LICENSE))
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.4.0/LICENSE))
- [github.com/go-ole/go-ole](https://pkg.go.dev/github.com/go-ole/go-ole) ([MIT](https://github.com/go-ole/go-ole/blob/v1.2.6/LICENSE))
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/v5.1.0/LICENSE))
@@ -50,9 +50,9 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.3.2/LICENSE.md))
- [github.com/kballard/go-shellquote](https://pkg.go.dev/github.com/kballard/go-shellquote) ([MIT](https://github.com/kballard/go-shellquote/blob/95032a82bc51/LICENSE))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.16.5/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.16.5/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.16.5/zstd/internal/xxhash/LICENSE.txt))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.16.7/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.16.7/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.16.7/zstd/internal/xxhash/LICENSE.txt))
- [github.com/kortschak/wol](https://pkg.go.dev/github.com/kortschak/wol) ([BSD-3-Clause](https://github.com/kortschak/wol/blob/da482cc4850a/LICENSE))
- [github.com/kr/fs](https://pkg.go.dev/github.com/kr/fs) ([BSD-3-Clause](https://github.com/kr/fs/blob/v0.1.0/LICENSE))
- [github.com/mattn/go-colorable](https://pkg.go.dev/github.com/mattn/go-colorable) ([MIT](https://github.com/mattn/go-colorable/blob/v0.1.13/LICENSE))
@@ -80,9 +80,9 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/vishvananda/netns](https://pkg.go.dev/github.com/vishvananda/netns) ([Apache-2.0](https://github.com/vishvananda/netns/blob/v0.0.4/LICENSE))
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/4f986261bf13/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/f1b76eb4bb35/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/ad4cb58a6516/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.11.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/47ecfdc1:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/515e97eb:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.10.0:LICENSE))
- [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.7.0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.2.0:LICENSE))

View File

@@ -12,6 +12,7 @@ import (
"net"
"net/netip"
"runtime"
"strings"
"sync/atomic"
"time"
@@ -139,14 +140,15 @@ func compileHostEntries(cfg Config) (hosts []*HostEntry) {
}
}
}
slices.SortFunc(hosts, func(a, b *HostEntry) bool {
if len(a.Hosts) == 0 {
return false
slices.SortFunc(hosts, func(a, b *HostEntry) int {
if len(a.Hosts) == 0 && len(b.Hosts) == 0 {
return 0
} else if len(a.Hosts) == 0 {
return -1
} else if len(b.Hosts) == 0 {
return 1
}
if len(b.Hosts) == 0 {
return true
}
return a.Hosts[0] < b.Hosts[0]
return strings.Compare(a.Hosts[0], b.Hosts[0])
})
return hosts
}

View File

@@ -366,8 +366,8 @@ func TestBasicRecursion(t *testing.T) {
netip.MustParseAddr("2600:9000:a602:b1e6:86d:8165:5e8c:295b"),
netip.MustParseAddr("2600:9000:a51d:27c1:1530:b9ef:2a6:b9e5"),
}
slices.SortFunc(addrs, func(x, y netip.Addr) bool { return x.String() < y.String() })
slices.SortFunc(wantAddrs, func(x, y netip.Addr) bool { return x.String() < y.String() })
slices.SortFunc(addrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) })
slices.SortFunc(wantAddrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) })
if !reflect.DeepEqual(addrs, wantAddrs) {
t.Errorf("got addrs=%+v; want %+v", addrs, wantAddrs)
@@ -485,8 +485,8 @@ func TestRecursionCNAME(t *testing.T) {
netip.MustParseAddr("13.248.141.131"),
netip.MustParseAddr("2600:9000:a602:b1e6:86d:8165:5e8c:295b"),
}
slices.SortFunc(addrs, func(x, y netip.Addr) bool { return x.String() < y.String() })
slices.SortFunc(wantAddrs, func(x, y netip.Addr) bool { return x.String() < y.String() })
slices.SortFunc(addrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) })
slices.SortFunc(wantAddrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) })
if !reflect.DeepEqual(addrs, wantAddrs) {
t.Errorf("got addrs=%+v; want %+v", addrs, wantAddrs)
@@ -590,8 +590,8 @@ func TestRecursionNoGlue(t *testing.T) {
netip.MustParseAddr("13.248.141.131"),
netip.MustParseAddr("2600:9000:a602:b1e6:86d:8165:5e8c:295b"),
}
slices.SortFunc(addrs, func(x, y netip.Addr) bool { return x.String() < y.String() })
slices.SortFunc(wantAddrs, func(x, y netip.Addr) bool { return x.String() < y.String() })
slices.SortFunc(addrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) })
slices.SortFunc(wantAddrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) })
if !reflect.DeepEqual(addrs, wantAddrs) {
t.Errorf("got addrs=%+v; want %+v", addrs, wantAddrs)

View File

@@ -22,6 +22,7 @@ import (
"sync/atomic"
"time"
"go4.org/netipx"
"golang.org/x/exp/slices"
"tailscale.com/atomicfile"
"tailscale.com/envknob"
@@ -76,11 +77,11 @@ func MakeLookupFunc(logf logger.Logf, netMon *netmon.Monitor) func(ctx context.C
metricRecursiveErrors.Add(1)
return
}
slices.SortFunc(addrs, func(a, b netip.Addr) bool { return a.Less(b) })
slices.SortFunc(addrs, netipx.CompareAddr)
// Wait for a response from the main function
oldAddrs := <-addrsCh
slices.SortFunc(oldAddrs, func(a, b netip.Addr) bool { return a.Less(b) })
slices.SortFunc(oldAddrs, netipx.CompareAddr)
matches := slices.Equal(addrs, oldAddrs)

View File

@@ -506,6 +506,8 @@ var getPAC func() string
// GetState returns the state of all the current machine's network interfaces.
//
// It does not set the returned State.IsExpensive. The caller can populate that.
//
// Deprecated: use netmon.Monitor.InterfaceState instead.
func GetState() (*State, error) {
s := &State{
InterfaceIPs: make(map[string][]netip.Prefix),

View File

@@ -166,6 +166,8 @@ type Client struct {
// NetMon optionally provides a netmon.Monitor to use to get the current
// (cached) network interface.
// If nil, the interface will be looked up dynamically.
// TODO(bradfitz): make NetMon required. As of 2023-08-01, it basically always is
// present anyway.
NetMon *netmon.Monitor
// TimeNow, if non-nil, is used instead of time.Now.

View File

@@ -51,7 +51,7 @@ func protocolsRequiredForForwarding(routes []netip.Prefix, state *interfaces.Sta
// CheckIPForwarding reports whether IP forwarding is enabled correctly
// for subnet routing and exit node functionality on any interface.
// The state param can be nil, in which case interfaces.GetState is used.
// The state param must not be nil.
// The routes should only be advertised routes, and should not contain the
// nodes Tailscale IPs.
// It returns an error if it is unable to determine if IP forwarding is enabled.
@@ -65,14 +65,10 @@ func CheckIPForwarding(routes []netip.Prefix, state *interfaces.State) (warn, er
}
return nil, nil
}
const kbLink = "\nSee https://tailscale.com/s/ip-forwarding"
if state == nil {
var err error
state, err = interfaces.GetState()
if err != nil {
return nil, err
}
return nil, fmt.Errorf("Couldn't check system's IP forwarding configuration; no link state")
}
const kbLink = "\nSee https://tailscale.com/s/ip-forwarding"
wantV4, wantV6 := protocolsRequiredForForwarding(routes, state)
if !wantV4 && !wantV6 {
return nil, nil

View File

@@ -10,6 +10,7 @@ import (
"net/netip"
"sync"
"go4.org/netipx"
"golang.org/x/exp/slices"
"tailscale.com/net/netaddr"
)
@@ -252,12 +253,7 @@ func ExitRoutes() []netip.Prefix { return []netip.Prefix{allIPv4, allIPv6} }
// SortPrefixes sorts the prefixes in place.
func SortPrefixes(p []netip.Prefix) {
slices.SortFunc(p, func(ri, rj netip.Prefix) bool {
if ri.Addr() == rj.Addr() {
return ri.Bits() < rj.Bits()
}
return ri.Addr().Less(rj.Addr())
})
slices.SortFunc(p, netipx.ComparePrefix)
}
// FilterPrefixes returns a new slice, not aliasing in, containing elements of

View File

@@ -15,8 +15,8 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/goreleaser/nfpm"
_ "github.com/goreleaser/nfpm/deb"
"github.com/goreleaser/nfpm/v2"
_ "github.com/goreleaser/nfpm/v2/deb"
)
func TestDebInfo(t *testing.T) {
@@ -38,6 +38,7 @@ func TestDebInfo(t *testing.T) {
"Section", "net",
"Priority", "extra",
"Architecture", "amd64",
"Maintainer", "Tail Scalar",
"Installed-Size", "0",
"Description", "test package"),
},
@@ -54,6 +55,7 @@ func TestDebInfo(t *testing.T) {
"Section", "net",
"Priority", "extra",
"Architecture", "arm64",
"Maintainer", "Tail Scalar",
"Installed-Size", "0",
"Description", "test package"),
},
@@ -70,6 +72,7 @@ func TestDebInfo(t *testing.T) {
"Section", "net",
"Priority", "extra",
"Architecture", "amd64",
"Maintainer", "Tail Scalar",
"Installed-Size", "0",
"Description", "test package"),
},
@@ -167,6 +170,7 @@ func mkTestDeb(version, arch string) []byte {
Version: version,
Section: "net",
Priority: "extra",
Maintainer: "Tail Scalar",
})
pkg, err := nfpm.Get("deb")

View File

@@ -6,6 +6,9 @@ package cli
import (
"context"
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"flag"
"fmt"
@@ -16,6 +19,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/release/dist"
"tailscale.com/release/dist/unixpkgs"
)
// CLI returns a CLI root command to build release packages.
@@ -23,7 +27,7 @@ import (
// getTargets is a function that gets run in the Exec function of commands that
// need to know the target list. Its execution is deferred in this way to allow
// customization of command FlagSets with flags that influence the target list.
func CLI(getTargets func() ([]dist.Target, error)) *ffcli.Command {
func CLI(getTargets func(unixpkgs.Signers) ([]dist.Target, error)) *ffcli.Command {
return &ffcli.Command{
Name: "dist",
ShortUsage: "dist [flags] <command> [command flags]",
@@ -33,7 +37,7 @@ func CLI(getTargets func() ([]dist.Target, error)) *ffcli.Command {
{
Name: "list",
Exec: func(ctx context.Context, args []string) error {
targets, err := getTargets()
targets, err := getTargets(unixpkgs.Signers{})
if err != nil {
return err
}
@@ -49,7 +53,11 @@ func CLI(getTargets func() ([]dist.Target, error)) *ffcli.Command {
{
Name: "build",
Exec: func(ctx context.Context, args []string) error {
targets, err := getTargets()
tgzSigner, err := parseSigningKey(buildArgs.tgzSigningKey)
if err != nil {
return err
}
targets, err := getTargets(unixpkgs.Signers{Tarball: tgzSigner})
if err != nil {
return err
}
@@ -61,6 +69,7 @@ func CLI(getTargets func() ([]dist.Target, error)) *ffcli.Command {
fs := flag.NewFlagSet("build", flag.ExitOnError)
fs.StringVar(&buildArgs.manifest, "manifest", "", "manifest file to write")
fs.BoolVar(&buildArgs.verbose, "verbose", false, "verbose logging")
fs.StringVar(&buildArgs.tgzSigningKey, "tgz-signing-key", "", "path to private signing key for release tarballs")
return fs
})(),
LongHelp: strings.TrimSpace(`
@@ -88,8 +97,9 @@ func runList(ctx context.Context, filters []string, targets []dist.Target) error
}
var buildArgs struct {
manifest string
verbose bool
manifest string
verbose bool
tgzSigningKey string
}
func runBuild(ctx context.Context, filters []string, targets []dist.Target) error {
@@ -142,3 +152,21 @@ func runBuild(ctx context.Context, filters []string, targets []dist.Target) erro
fmt.Println("Done! Took", time.Since(st))
return nil
}
func parseSigningKey(path string) (crypto.Signer, error) {
if path == "" {
return nil, nil
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
b, rest := pem.Decode(raw)
if b == nil {
return nil, fmt.Errorf("failed to decode PEM data in %q", path)
}
if len(rest) > 0 {
return nil, fmt.Errorf("trailing data in %q, please check that the key file was not corrupted", path)
}
return x509.ParseECPrivateKey(b.Bytes)
}

View File

@@ -7,6 +7,9 @@ package unixpkgs
import (
"archive/tar"
"compress/gzip"
"crypto"
"crypto/rand"
"crypto/sha512"
"errors"
"fmt"
"io"
@@ -15,24 +18,26 @@ import (
"path/filepath"
"strings"
"github.com/goreleaser/nfpm"
"github.com/goreleaser/nfpm/v2"
"github.com/goreleaser/nfpm/v2/files"
"tailscale.com/release/dist"
)
type tgzTarget struct {
filenameArch string // arch to use in filename instead of deriving from goenv["GOARCH"]
goenv map[string]string
filenameArch string // arch to use in filename instead of deriving from goEnv["GOARCH"]
goEnv map[string]string
signer crypto.Signer
}
func (t *tgzTarget) arch() string {
if t.filenameArch != "" {
return t.filenameArch
}
return t.goenv["GOARCH"]
return t.goEnv["GOARCH"]
}
func (t *tgzTarget) os() string {
return t.goenv["GOOS"]
return t.goEnv["GOOS"]
}
func (t *tgzTarget) String() string {
@@ -41,18 +46,18 @@ func (t *tgzTarget) String() string {
func (t *tgzTarget) Build(b *dist.Build) ([]string, error) {
var filename string
if t.goenv["GOOS"] == "linux" {
if t.goEnv["GOOS"] == "linux" {
// Linux used to be the only tgz architecture, so we didn't put the OS
// name in the filename.
filename = fmt.Sprintf("tailscale_%s_%s.tgz", b.Version.Short, t.arch())
} else {
filename = fmt.Sprintf("tailscale_%s_%s_%s.tgz", b.Version.Short, t.os(), t.arch())
}
ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goenv)
ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goEnv)
if err != nil {
return nil, err
}
tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goenv)
tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goEnv)
if err != nil {
return nil, err
}
@@ -65,7 +70,11 @@ func (t *tgzTarget) Build(b *dist.Build) ([]string, error) {
return nil, err
}
defer f.Close()
gw := gzip.NewWriter(f)
// Hash the final output we're writing to the file, after tar and gzip
// writers did their thing.
h := sha512.New()
hw := io.MultiWriter(f, h)
gw := gzip.NewWriter(hw)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
@@ -146,23 +155,37 @@ func (t *tgzTarget) Build(b *dist.Build) ([]string, error) {
return nil, err
}
return []string{filename}, nil
files := []string{filename}
if t.signer != nil {
sig, err := t.signer.Sign(rand.Reader, h.Sum(nil), crypto.SHA512)
if err != nil {
return nil, err
}
sigFilename := out + ".sig"
if err := os.WriteFile(sigFilename, sig, 0644); err != nil {
return nil, err
}
files = append(files, filename+".sig")
}
return files, nil
}
type debTarget struct {
goenv map[string]string
goEnv map[string]string
}
func (t *debTarget) os() string {
return t.goenv["GOOS"]
return t.goEnv["GOOS"]
}
func (t *debTarget) arch() string {
return t.goenv["GOARCH"]
return t.goEnv["GOARCH"]
}
func (t *debTarget) String() string {
return fmt.Sprintf("linux/%s/deb", t.goenv["GOARCH"])
return fmt.Sprintf("linux/%s/deb", t.goEnv["GOARCH"])
}
func (t *debTarget) Build(b *dist.Build) ([]string, error) {
@@ -170,11 +193,11 @@ func (t *debTarget) Build(b *dist.Build) ([]string, error) {
return nil, errors.New("deb only supported on linux")
}
ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goenv)
ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goEnv)
if err != nil {
return nil, err
}
tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goenv)
tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goEnv)
if err != nil {
return nil, err
}
@@ -189,6 +212,31 @@ func (t *debTarget) Build(b *dist.Build) ([]string, error) {
}
arch := debArch(t.arch())
contents, err := files.PrepareForPackager(files.Contents{
&files.Content{
Type: files.TypeFile,
Source: ts,
Destination: "/usr/bin/tailscale",
},
&files.Content{
Type: files.TypeFile,
Source: tsd,
Destination: "/usr/sbin/tailscaled",
},
&files.Content{
Type: files.TypeFile,
Source: filepath.Join(tailscaledDir, "tailscaled.service"),
Destination: "/lib/systemd/system/tailscaled.service",
},
&files.Content{
Type: files.TypeConfigNoReplace,
Source: filepath.Join(tailscaledDir, "tailscaled.defaults"),
Destination: "/etc/default/tailscaled",
},
}, 0, "deb", false)
if err != nil {
return nil, err
}
info := nfpm.WithDefaults(&nfpm.Info{
Name: "tailscale",
Arch: arch,
@@ -201,14 +249,7 @@ func (t *debTarget) Build(b *dist.Build) ([]string, error) {
Section: "net",
Priority: "extra",
Overridables: nfpm.Overridables{
Files: map[string]string{
ts: "/usr/bin/tailscale",
tsd: "/usr/sbin/tailscaled",
filepath.Join(tailscaledDir, "tailscaled.service"): "/lib/systemd/system/tailscaled.service",
},
ConfigFiles: map[string]string{
filepath.Join(tailscaledDir, "tailscaled.defaults"): "/etc/default/tailscaled",
},
Contents: contents,
Scripts: nfpm.Scripts{
PostInstall: filepath.Join(repoDir, "release/deb/debian.postinst.sh"),
PreRemove: filepath.Join(repoDir, "release/deb/debian.prerm.sh"),
@@ -243,15 +284,16 @@ func (t *debTarget) Build(b *dist.Build) ([]string, error) {
}
type rpmTarget struct {
goenv map[string]string
goEnv map[string]string
signFn func(io.Reader) ([]byte, error)
}
func (t *rpmTarget) os() string {
return t.goenv["GOOS"]
return t.goEnv["GOOS"]
}
func (t *rpmTarget) arch() string {
return t.goenv["GOARCH"]
return t.goEnv["GOARCH"]
}
func (t *rpmTarget) String() string {
@@ -263,11 +305,11 @@ func (t *rpmTarget) Build(b *dist.Build) ([]string, error) {
return nil, errors.New("rpm only supported on linux")
}
ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goenv)
ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goEnv)
if err != nil {
return nil, err
}
tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goenv)
tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goEnv)
if err != nil {
return nil, err
}
@@ -282,6 +324,37 @@ func (t *rpmTarget) Build(b *dist.Build) ([]string, error) {
}
arch := rpmArch(t.arch())
contents, err := files.PrepareForPackager(files.Contents{
&files.Content{
Type: files.TypeFile,
Source: ts,
Destination: "/usr/bin/tailscale",
},
&files.Content{
Type: files.TypeFile,
Source: tsd,
Destination: "/usr/sbin/tailscaled",
},
&files.Content{
Type: files.TypeFile,
Source: filepath.Join(tailscaledDir, "tailscaled.service"),
Destination: "/lib/systemd/system/tailscaled.service",
},
&files.Content{
Type: files.TypeConfigNoReplace,
Source: filepath.Join(tailscaledDir, "tailscaled.defaults"),
Destination: "/etc/default/tailscaled",
},
// SELinux policy on e.g. CentOS 8 forbids writing to /var/cache.
// Creating an empty directory at install time resolves this issue.
&files.Content{
Type: files.TypeDir,
Destination: "/var/cache/tailscale",
},
}, 0, "rpm", false)
if err != nil {
return nil, err
}
info := nfpm.WithDefaults(&nfpm.Info{
Name: "tailscale",
Arch: arch,
@@ -292,17 +365,7 @@ func (t *rpmTarget) Build(b *dist.Build) ([]string, error) {
Homepage: "https://www.tailscale.com",
License: "MIT",
Overridables: nfpm.Overridables{
Files: map[string]string{
ts: "/usr/bin/tailscale",
tsd: "/usr/sbin/tailscaled",
filepath.Join(tailscaledDir, "tailscaled.service"): "/lib/systemd/system/tailscaled.service",
},
ConfigFiles: map[string]string{
filepath.Join(tailscaledDir, "tailscaled.defaults"): "/etc/default/tailscaled",
},
// SELinux policy on e.g. CentOS 8 forbids writing to /var/cache.
// Creating an empty directory at install time resolves this issue.
EmptyFolders: []string{"/var/cache/tailscale"},
Contents: contents,
Scripts: nfpm.Scripts{
PostInstall: filepath.Join(repoDir, "release/rpm/rpm.postinst.sh"),
PreRemove: filepath.Join(repoDir, "release/rpm/rpm.prerm.sh"),
@@ -313,6 +376,11 @@ func (t *rpmTarget) Build(b *dist.Build) ([]string, error) {
Conflicts: []string{"tailscale-relay"},
RPM: nfpm.RPM{
Group: "Network",
Signature: nfpm.RPMSignature{
PackageSignature: nfpm.PackageSignature{
SignFn: t.signFn,
},
},
},
},
})

View File

@@ -4,31 +4,39 @@
package unixpkgs
import (
"crypto"
"fmt"
"io"
"sort"
"strings"
"tailscale.com/release/dist"
_ "github.com/goreleaser/nfpm/deb"
_ "github.com/goreleaser/nfpm/rpm"
_ "github.com/goreleaser/nfpm/v2/deb"
_ "github.com/goreleaser/nfpm/v2/rpm"
)
func Targets() []dist.Target {
type Signers struct {
Tarball crypto.Signer
RPM func(io.Reader) ([]byte, error)
}
func Targets(signers Signers) []dist.Target {
var ret []dist.Target
for goosgoarch := range tarballs {
goos, goarch := splitGoosGoarch(goosgoarch)
ret = append(ret, &tgzTarget{
goenv: map[string]string{
goEnv: map[string]string{
"GOOS": goos,
"GOARCH": goarch,
},
signer: signers.Tarball,
})
}
for goosgoarch := range debs {
goos, goarch := splitGoosGoarch(goosgoarch)
ret = append(ret, &debTarget{
goenv: map[string]string{
goEnv: map[string]string{
"GOOS": goos,
"GOARCH": goarch,
},
@@ -37,10 +45,11 @@ func Targets() []dist.Target {
for goosgoarch := range rpms {
goos, goarch := splitGoosGoarch(goosgoarch)
ret = append(ret, &rpmTarget{
goenv: map[string]string{
goEnv: map[string]string{
"GOOS": goos,
"GOARCH": goarch,
},
signFn: signers.RPM,
})
}
@@ -48,11 +57,12 @@ func Targets() []dist.Target {
// an ancient architecture.
ret = append(ret, &tgzTarget{
filenameArch: "geode",
goenv: map[string]string{
goEnv: map[string]string{
"GOOS": "linux",
"GOARCH": "386",
"GO386": "softfloat",
},
signer: signers.Tarball,
})
sort.Slice(ret, func(i, j int) bool {

View File

@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-hWfdcvm2ief313JMgzDIispAnwi+D1iWsm0UHWOomxg=
# nix-direnv cache busting line: sha256-Fr4VZcKrXnT1PZuEG110KBefjcZzRsQRBSvByELKAy4=

View File

@@ -1223,6 +1223,25 @@ const (
// the application via the WhoIs API.
type PeerCapMap map[PeerCapability][]json.RawMessage
// UnmarshalCapJSON unmarshals each JSON value in cm[cap] as T.
// If cap does not exist in cm, it returns (nil, nil).
// It returns an error if the values cannot be unmarshaled into the provided type.
func UnmarshalCapJSON[T any](cm PeerCapMap, cap PeerCapability) ([]T, error) {
vals, ok := cm[cap]
if !ok {
return nil, nil
}
out := make([]T, 0, len(vals))
for _, v := range vals {
var t T
if err := json.Unmarshal(v, &t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, nil
}
// HasCapability reports whether c has the capability cap.
// This is used to test for the existence of a capability, especially
// when the capability has no values.
@@ -1921,6 +1940,7 @@ const (
CapabilitySSHRuleIn = "https://tailscale.com/cap/ssh-rule-in" // some SSH rule reach this node
CapabilityDataPlaneAuditLogs = "https://tailscale.com/cap/data-plane-audit-logs" // feature enabled
CapabilityDebug = "https://tailscale.com/cap/debug" // exposes debug endpoints over the PeerAPI
CapabilityHTTPS = "https" // https cert provisioning enabled on tailnet
// CapabilityBindToInterfaceByRoute changes how Darwin nodes create
// sockets (in the net/netns package). See that package for more
@@ -2237,6 +2257,46 @@ type SSHRecordingAttempt struct {
FailureMessage string
}
// QueryFeatureRequest is a request sent to "/machine/feature/query"
// to get instructions on how to enable a feature, such as Funnel,
// for the node's tailnet.
//
// See QueryFeatureResponse for response structure.
type QueryFeatureRequest struct {
// Feature is the string identifier for a feature.
Feature string `json:",omitempty"`
// NodeKey is the client's current node key.
NodeKey key.NodePublic `json:",omitempty"`
}
// QueryFeatureResponse is the response to an QueryFeatureRequest.
type QueryFeatureResponse struct {
// Complete is true when the feature is already enabled.
Complete bool `json:",omitempty"`
// Text holds lines to display in the CLI with information
// about the feature and how to enable it.
//
// Lines are separated by newline characters. The final
// newline may be omitted.
Text string `json:",omitempty"`
// URL is the link for the user to visit to take action on
// enabling the feature.
//
// When empty, there is no action for this user to take.
URL string `json:",omitempty"`
// WaitOn specifies the self node capability required to use
// the feature. The CLI can watch for changes to the presence,
// of this capability, and once included, can proceed with
// using the feature.
//
// If WaitOn is empty, the user does not have an action that
// the CLI should block on.
WaitOn string `json:",omitempty"`
}
// OverTLSPublicKeyResponse is the JSON response to /key?v=<n>
// over HTTPS (regular TLS) to the Tailscale control plane server,
// where the 'v' argument is the client's current capability version

View File

@@ -28,6 +28,9 @@ var cborDecOpts = cbor.DecOptions{
MaxMapPairs: 1024,
}
// Arbitrarily chosen limit on scanning AUM trees.
const maxScanIterations = 2000
// Authority is a Tailnet Key Authority. This type is the main coupling
// point to the rest of the tailscale client.
//
@@ -471,7 +474,7 @@ func Open(storage Chonk) (*Authority, error) {
return nil, fmt.Errorf("reading last ancestor: %v", err)
}
c, err := computeActiveChain(storage, a, 2000)
c, err := computeActiveChain(storage, a, maxScanIterations)
if err != nil {
return nil, fmt.Errorf("active chain: %v", err)
}
@@ -604,7 +607,7 @@ func (a *Authority) InformIdempotent(storage Chonk, updates []AUM) (Authority, e
state, hasState := stateAt[parent]
var err error
if !hasState {
if state, err = computeStateAt(storage, 2000, parent); err != nil {
if state, err = computeStateAt(storage, maxScanIterations, parent); err != nil {
return Authority{}, fmt.Errorf("update %d computing state: %v", i, err)
}
stateAt[parent] = state
@@ -639,7 +642,7 @@ func (a *Authority) InformIdempotent(storage Chonk, updates []AUM) (Authority, e
}
oldestAncestor := a.oldestAncestor.Hash()
c, err := computeActiveChain(storage, &oldestAncestor, 2000)
c, err := computeActiveChain(storage, &oldestAncestor, maxScanIterations)
if err != nil {
return Authority{}, fmt.Errorf("recomputing active chain: %v", err)
}
@@ -721,3 +724,115 @@ func (a *Authority) Compact(storage CompactableChonk, o CompactionOptions) error
a.oldestAncestor = ancestor
return nil
}
// findParentForRewrite finds the parent AUM to use when rewriting state to
// retroactively remove trust in the specified keys.
func (a *Authority) findParentForRewrite(storage Chonk, removeKeys []tkatype.KeyID, ourKey tkatype.KeyID) (AUMHash, error) {
cursor := a.Head()
for {
if cursor == a.oldestAncestor.Hash() {
// We've reached as far back in our history as we can,
// so we have to rewrite from here.
break
}
aum, err := storage.AUM(cursor)
if err != nil {
return AUMHash{}, fmt.Errorf("reading AUM %v: %w", cursor, err)
}
// An ideal rewrite parent trusts none of the keys to be removed.
state, err := computeStateAt(storage, maxScanIterations, cursor)
if err != nil {
return AUMHash{}, fmt.Errorf("computing state for %v: %w", cursor, err)
}
keyTrusted := false
for _, key := range removeKeys {
if _, err := state.GetKey(key); err == nil {
keyTrusted = true
}
}
if !keyTrusted {
// Success: the revoked keys are not trusted!
// Lets check that our key was trusted to ensure
// we can sign a fork from here.
if _, err := state.GetKey(ourKey); err == nil {
break
}
}
parent, hasParent := aum.Parent()
if !hasParent {
// This is the genesis AUM, so we have to rewrite from here.
break
}
cursor = parent
}
return cursor, nil
}
// MakeRetroactiveRevocation generates a forking update which revokes the specified keys, in
// such a manner that any malicious use of those keys is erased.
//
// If forkFrom is specified, it is used as the parent AUM to fork from. If the zero value,
// the parent AUM is determined automatically.
//
// The generated AUM must be signed with more signatures than the sum of key votes that
// were compromised, before being consumed by tka.Authority methods.
func (a *Authority) MakeRetroactiveRevocation(storage Chonk, removeKeys []tkatype.KeyID, ourKey tkatype.KeyID, forkFrom AUMHash) (*AUM, error) {
var parent AUMHash
if forkFrom == (AUMHash{}) {
// Make sure at least one of the recovery keys is currently trusted.
foundKey := false
for _, k := range removeKeys {
if _, err := a.state.GetKey(k); err == nil {
foundKey = true
break
}
}
if !foundKey {
return nil, errors.New("no provided key is currently trusted")
}
p, err := a.findParentForRewrite(storage, removeKeys, ourKey)
if err != nil {
return nil, fmt.Errorf("finding parent: %v", err)
}
parent = p
} else {
parent = forkFrom
}
// Construct the new state where the revoked keys are no longer trusted.
state := a.state.Clone()
for _, keyToRevoke := range removeKeys {
idx := -1
for i := range state.Keys {
keyID, err := state.Keys[i].ID()
if err != nil {
return nil, fmt.Errorf("computing keyID: %v", err)
}
if bytes.Equal(keyToRevoke, keyID) {
idx = i
break
}
}
if idx >= 0 {
state.Keys = append(state.Keys[:idx], state.Keys[idx+1:]...)
}
}
if len(state.Keys) == 0 {
return nil, errors.New("cannot revoke all trusted keys")
}
state.LastAUMHash = nil // checkpoints can't specify a LastAUMHash
forkingAUM := &AUM{
MessageKind: AUMCheckpoint,
State: &state,
PrevAUMHash: parent[:],
}
return forkingAUM, forkingAUM.StaticValidate()
}

View File

@@ -524,3 +524,131 @@ func TestAuthorityCompact(t *testing.T) {
t.Errorf("ancestor = %v, want %v", anc, c.AUMHashes["C"])
}
}
func TestFindParentForRewrite(t *testing.T) {
pub, _ := testingKey25519(t, 1)
k1 := Key{Kind: Key25519, Public: pub, Votes: 1}
pub2, _ := testingKey25519(t, 2)
k2 := Key{Kind: Key25519, Public: pub2, Votes: 1}
k2ID, _ := k2.ID()
pub3, _ := testingKey25519(t, 3)
k3 := Key{Kind: Key25519, Public: pub3, Votes: 1}
c := newTestchain(t, `
A -> B -> C -> D -> E
A.template = genesis
B.template = add2
C.template = add3
D.template = remove2
`,
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
Keys: []Key{k1},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}}),
optTemplate("add2", AUM{MessageKind: AUMAddKey, Key: &k2}),
optTemplate("add3", AUM{MessageKind: AUMAddKey, Key: &k3}),
optTemplate("remove2", AUM{MessageKind: AUMRemoveKey, KeyID: k2ID}))
a, err := Open(c.Chonk())
if err != nil {
t.Fatal(err)
}
// k1 was trusted at genesis, so there's no better rewrite parent
// than the genesis.
k1ID, _ := k1.ID()
k1P, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k1ID}, k1ID)
if err != nil {
t.Fatalf("FindParentForRewrite(k1) failed: %v", err)
}
if k1P != a.oldestAncestor.Hash() {
t.Errorf("FindParentForRewrite(k1) = %v, want %v", k1P, a.oldestAncestor.Hash())
}
// k3 was trusted at C, so B would be an ideal rewrite point.
k3ID, _ := k3.ID()
k3P, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k3ID}, k1ID)
if err != nil {
t.Fatalf("FindParentForRewrite(k3) failed: %v", err)
}
if k3P != c.AUMHashes["B"] {
t.Errorf("FindParentForRewrite(k3) = %v, want %v", k3P, c.AUMHashes["B"])
}
// k2 was added but then removed, so HEAD is an appropriate rewrite point.
k2P, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k2ID}, k1ID)
if err != nil {
t.Fatalf("FindParentForRewrite(k2) failed: %v", err)
}
if k3P != c.AUMHashes["B"] {
t.Errorf("FindParentForRewrite(k2) = %v, want %v", k2P, a.Head())
}
// There's no appropriate point where both k2 and k3 are simultaneously not trusted,
// so the best rewrite point is the genesis AUM.
doubleP, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k2ID, k3ID}, k1ID)
if err != nil {
t.Fatalf("FindParentForRewrite({k2, k3}) failed: %v", err)
}
if doubleP != a.oldestAncestor.Hash() {
t.Errorf("FindParentForRewrite({k2, k3}) = %v, want %v", doubleP, a.oldestAncestor.Hash())
}
}
func TestMakeRetroactiveRevocation(t *testing.T) {
pub, _ := testingKey25519(t, 1)
k1 := Key{Kind: Key25519, Public: pub, Votes: 1}
pub2, _ := testingKey25519(t, 2)
k2 := Key{Kind: Key25519, Public: pub2, Votes: 1}
pub3, _ := testingKey25519(t, 3)
k3 := Key{Kind: Key25519, Public: pub3, Votes: 1}
c := newTestchain(t, `
A -> B -> C -> D
A.template = genesis
C.template = add2
D.template = add3
`,
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
Keys: []Key{k1},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}}),
optTemplate("add2", AUM{MessageKind: AUMAddKey, Key: &k2}),
optTemplate("add3", AUM{MessageKind: AUMAddKey, Key: &k3}))
a, err := Open(c.Chonk())
if err != nil {
t.Fatal(err)
}
// k2 was added by C, so a forking revocation should:
// - have B as a parent
// - trust the remaining keys at the time, k1 & k3.
k1ID, _ := k1.ID()
k2ID, _ := k2.ID()
k3ID, _ := k3.ID()
forkingAUM, err := a.MakeRetroactiveRevocation(c.Chonk(), []tkatype.KeyID{k2ID}, k1ID, AUMHash{})
if err != nil {
t.Fatalf("MakeRetroactiveRevocation(k2) failed: %v", err)
}
if bHash := c.AUMHashes["B"]; !bytes.Equal(forkingAUM.PrevAUMHash, bHash[:]) {
t.Errorf("forking AUM has parent %v, want %v", forkingAUM.PrevAUMHash, bHash[:])
}
if _, err := forkingAUM.State.GetKey(k1ID); err != nil {
t.Error("Forked state did not trust k1")
}
if _, err := forkingAUM.State.GetKey(k3ID); err != nil {
t.Error("Forked state did not trust k3")
}
if _, err := forkingAUM.State.GetKey(k2ID); err == nil {
t.Error("Forked state trusted removed-key k2")
}
// Test that removing all trusted keys results in an error.
_, err = a.MakeRetroactiveRevocation(c.Chonk(), []tkatype.KeyID{k1ID, k2ID, k3ID}, k1ID, AUMHash{})
if wantErr := "cannot revoke all trusted keys"; err == nil || err.Error() != wantErr {
t.Fatalf("MakeRetroactiveRevocation({k1, k2, k3}) returned %v, expected %q", err, wantErr)
}
}

60
tool/node Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Run a command with our local node install, rather than any globally installed
# instance.
set -euo pipefail
if [[ "${CI:-}" == "true" ]]; then
set -x
fi
(
if [[ "${CI:-}" == "true" ]]; then
set -x
fi
repo_root="${BASH_SOURCE%/*}/../"
cd "$repo_root"
cachedir="$HOME/.cache/tailscale-node"
tarball="${cachedir}.tar.gz"
read -r want_rev < "$(dirname "$0")/node.rev"
got_rev=""
if [[ -x "${cachedir}/bin/node" ]]; then
got_rev=$("${cachedir}/bin/node" --version)
got_rev="${got_rev#v}" # trim the leading 'v'
fi
if [[ "$want_rev" != "$got_rev" ]]; then
rm -rf "$cachedir" "$tarball"
if [[ -n "${IN_NIX_SHELL:-}" ]]; then
nix_node="$(which -a node | grep /nix/store | head -1)"
nix_node="${nix_node%/bin/node}"
nix_node_rev="${nix_node##*-}"
if [[ "$nix_node_rev" != "$want_rev" ]]; then
echo "Wrong node version in Nix, got $nix_node_rev want $want_rev" >&2
exit 1
fi
ln -sf "$nix_node" "$cachedir"
else
# works for "linux" and "darwin"
OS=$(uname -s | tr A-Z a-z)
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
ARCH="x64"
fi
if [ "$ARCH" = "aarch64" ]; then
ARCH="arm64"
fi
mkdir -p "$cachedir"
curl -f -L -o "$tarball" "https://nodejs.org/dist/v${want_rev}/node-v${want_rev}-${OS}-${ARCH}.tar.gz"
(cd "$cachedir" && tar --strip-components=1 -xf "$tarball")
rm -f "$tarball"
fi
fi
)
export PATH="$HOME/.cache/tailscale-node/bin:$PATH"
exec "$HOME/.cache/tailscale-node/bin/node" "$@"

View File

@@ -1 +1 @@
16.4.1
18.16.1

104
tool/yarn
View File

@@ -1,79 +1,43 @@
#!/bin/sh
#
# This script acts like the "yarn" command, but uses Tailscale's
# currently-desired version, downloading it (and node) first if necessary.
#!/usr/bin/env bash
# Run a command with our local yarn install, rather than any globally installed
# instance.
set -eu
set -euo pipefail
NODE_DIR="$HOME/.cache/tailscale-node"
read -r YARN_REV < "$(dirname "$0")/yarn.rev"
YARN_DIR="$HOME/.cache/tailscale-yarn"
# This works for linux and darwin, which is sufficient
# (we do not build for other targets).
OS=$(uname -s | tr A-Z a-z)
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ]; then
# Node uses the name "arm64".
ARCH="arm64"
elif [ "$ARCH" = "x86_64" ]; then
# Node uses the name "x64".
ARCH="x64"
if [[ "${CI:-}" == "true" ]]; then
set -x
fi
install_node() {
read -r NODE_REV < "$(dirname "$0")/node.rev"
NODE_URL="https://nodejs.org/dist/v${NODE_REV}/node-v${NODE_REV}-${OS}-${ARCH}.tar.gz"
install_tool "node" $NODE_REV $NODE_DIR $NODE_URL
}
install_yarn() {
YARN_URL="https://github.com/yarnpkg/yarn/releases/download/v$YARN_REV/yarn-v$YARN_REV.tar.gz"
install_tool "yarn" $YARN_REV $YARN_DIR $YARN_URL
}
install_tool() {
TOOL=$1
REV=$2
TOOLCHAIN=$3
URL=$4
archive="$TOOLCHAIN-$REV.tar.gz"
mark="$TOOLCHAIN.extracted"
extracted=
[ ! -e "$mark" ] || read -r extracted junk <$mark
if [ "$extracted" = "$REV" ] && [ -e "$TOOLCHAIN/bin/$TOOL" ]; then
# Already extracted, continue silently
return 0
(
if [[ "${CI:-}" == "true" ]]; then
set -x
fi
rm -f "$archive.new" "$TOOLCHAIN.extracted"
if [ ! -e "$archive" ]; then
log "Need to download $TOOL '$REV' from $URL."
curl -f -L -o "$archive.new" $URL
rm -f "$archive"
mv "$archive.new" "$archive"
repo_root="${BASH_SOURCE%/*}/../"
cd "$repo_root"
./tool/node --version >/dev/null # Ensure node is unpacked and ready
cachedir="$HOME/.cache/tailscale-yarn"
tarball="${cachedir}.tar.gz"
read -r want_rev < "$(dirname "$0")/yarn.rev"
got_rev=""
if [[ -x "${cachedir}/bin/yarn" ]]; then
got_rev=$(PATH="$HOME/.cache/tailscale-node/bin:$PATH" "${cachedir}/bin/yarn" --version)
fi
log "Extracting $TOOL '$REV' into '$TOOLCHAIN'." >&2
rm -rf "$TOOLCHAIN"
mkdir -p "$TOOLCHAIN"
(cd "$TOOLCHAIN" && tar --strip-components=1 -xf "$archive")
echo "$REV" >$mark
}
if [[ "$want_rev" != "$got_rev" ]]; then
rm -rf "$cachedir" "$tarball"
mkdir -p "$cachedir"
curl -f -L -o "$tarball" "https://github.com/yarnpkg/yarn/releases/download/v${want_rev}/yarn-v${want_rev}.tar.gz"
(cd "$cachedir" && tar --strip-components=1 -xf "$tarball")
rm -f "$tarball"
fi
)
log() {
echo "$@" >&2
}
if [ "${YARN_REV}" = "SKIP" ] ||
[ "${OS}" != "darwin" -a "${OS}" != "linux" ] ||
[ "${ARCH}" != "x64" -a "${ARCH}" != "arm64" ]; then
log "Using existing yarn (`which yarn`)."
exec yarn "$@"
fi
install_node
install_yarn
exec /usr/bin/env PATH="$NODE_DIR/bin:$PATH" "$YARN_DIR/bin/yarn" "$@"
# Deliberately not using cachedir here, to keep the environment
# completely pristine for execution of yarn.
export PATH="$HOME/.cache/tailscale-node/bin:$HOME/.cache/tailscale-yarn/bin:$PATH"
exec "$HOME/.cache/tailscale-yarn/bin/yarn" "$@"

View File

@@ -52,6 +52,7 @@ import (
_ "tailscale.com/types/logid"
_ "tailscale.com/util/clientmetric"
_ "tailscale.com/util/multierr"
_ "tailscale.com/util/osdiag"
_ "tailscale.com/util/osshare"
_ "tailscale.com/util/winutil"
_ "tailscale.com/version"

View File

@@ -84,7 +84,11 @@ func (id PublicID) String() string {
}
func (id1 PublicID) Less(id2 PublicID) bool {
return slices.Compare(id1[:], id2[:]) < 0
return id1.Compare(id2) < 0
}
func (id1 PublicID) Compare(id2 PublicID) int {
return slices.Compare(id1[:], id2[:])
}
func (id PublicID) IsZero() bool {

View File

@@ -20,3 +20,40 @@ func Or[T comparable](list ...T) T {
}
return zero
}
// Ordered is cmp.Ordered from Go 1.21.
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}
// Compare returns
//
// -1 if x is less than y,
// 0 if x equals y,
// +1 if x is greater than y.
//
// For floating-point types, a NaN is considered less than any non-NaN,
// a NaN is considered equal to a NaN, and -0.0 is equal to 0.0.
func Compare[T Ordered](x, y T) int {
xNaN := isNaN(x)
yNaN := isNaN(y)
if xNaN && yNaN {
return 0
}
if xNaN || x < y {
return -1
}
if yNaN || x > y {
return +1
}
return 0
}
// isNaN reports whether x is a NaN without requiring the math package.
// This will always return false if T is not floating-point.
func isNaN[T Ordered](x T) bool {
return x != x
}

9
util/osdiag/mksyscall.go Normal file
View File

@@ -0,0 +1,9 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package osdiag
//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go mksyscall.go
//go:generate go run golang.org/x/tools/cmd/goimports -w zsyscall_windows.go
//sys regEnumValue(key registry.Key, index uint32, valueName *uint16, valueNameLen *uint32, reserved *uint32, valueType *uint32, pData *byte, cbData *uint32) (ret error) [failretval!=0] = advapi32.RegEnumValueW

23
util/osdiag/osdiag.go Normal file
View File

@@ -0,0 +1,23 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package osdiag provides loggers for OS-specific diagnostic information.
package osdiag
import "tailscale.com/types/logger"
// LogSupportInfoReason is an enumeration indicating the reason for logging
// support info.
type LogSupportInfoReason int
const (
LogSupportInfoReasonStartup LogSupportInfoReason = iota + 1 // tailscaled is starting up.
LogSupportInfoReasonBugReport // a bugreport is in the process of being gathered.
)
// LogSupportInfo obtains OS-specific diagnostic information useful for
// troubleshooting and support, and writes it to logf. The reason argument is
// useful for governing the verbosity of this function's output.
func LogSupportInfo(logf logger.Logf, reason LogSupportInfoReason) {
logSupportInfo(logf, reason)
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows
package osdiag
import "tailscale.com/types/logger"
func logSupportInfo(logger.Logf, LogSupportInfoReason) {
}

View File

@@ -0,0 +1,330 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package osdiag
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"unicode/utf16"
"unsafe"
"github.com/dblohm7/wingoes/pe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"tailscale.com/types/logger"
"tailscale.com/util/winutil"
"tailscale.com/util/winutil/authenticode"
)
const (
maxBinaryValueLen = 128 // we'll truncate any binary values longer than this
maxRegValueNameLen = 16384 // maximum length supported by Windows + 1
initialValueBufLen = 80 // large enough to contain a stringified GUID encoded as UTF-16
)
func logSupportInfo(logf logger.Logf, reason LogSupportInfoReason) {
var b strings.Builder
if err := getSupportInfo(&b, reason); err != nil {
logf("error encoding support info: %v", err)
return
}
logf("%s", b.String())
}
const (
supportInfoKeyModules = "modules"
supportInfoKeyRegistry = "registry"
)
func getSupportInfo(w io.Writer, reason LogSupportInfoReason) error {
output := make(map[string]any)
regInfo, err := getRegistrySupportInfo(registry.LOCAL_MACHINE, []string{`SOFTWARE\Policies\Tailscale`, winutil.RegBase})
if err == nil {
output[supportInfoKeyRegistry] = regInfo
} else {
output[supportInfoKeyRegistry] = err
}
if reason == LogSupportInfoReasonBugReport {
modInfo, err := getModuleInfo()
if err == nil {
output[supportInfoKeyModules] = modInfo
} else {
output[supportInfoKeyModules] = err
}
}
enc := json.NewEncoder(w)
return enc.Encode(output)
}
type getRegistrySupportInfoBufs struct {
nameBuf []uint16
valueBuf []byte
}
func getRegistrySupportInfo(root registry.Key, subKeys []string) (map[string]any, error) {
bufs := getRegistrySupportInfoBufs{
nameBuf: make([]uint16, maxRegValueNameLen),
valueBuf: make([]byte, initialValueBufLen),
}
output := make(map[string]any)
for _, subKey := range subKeys {
if err := getRegSubKey(root, subKey, 5, &bufs, output); err != nil && !errors.Is(err, registry.ErrNotExist) {
return nil, fmt.Errorf("getRegistrySupportInfo: %w", err)
}
}
return output, nil
}
func keyString(key registry.Key, subKey string) string {
var keyStr string
switch key {
case registry.CLASSES_ROOT:
keyStr = `HKCR\`
case registry.CURRENT_USER:
keyStr = `HKCU\`
case registry.LOCAL_MACHINE:
keyStr = `HKLM\`
case registry.USERS:
keyStr = `HKU\`
case registry.CURRENT_CONFIG:
keyStr = `HKCC\`
case registry.PERFORMANCE_DATA:
keyStr = `HKPD\`
default:
}
return keyStr + subKey
}
func getRegSubKey(key registry.Key, subKey string, recursionLimit int, bufs *getRegistrySupportInfoBufs, output map[string]any) error {
keyStr := keyString(key, subKey)
k, err := registry.OpenKey(key, subKey, registry.READ)
if err != nil {
return fmt.Errorf("opening %q: %w", keyStr, err)
}
defer k.Close()
kv := make(map[string]any)
index := uint32(0)
loopValues:
for {
nbuf := bufs.nameBuf
nameLen := uint32(len(nbuf))
valueType := uint32(0)
vbuf := bufs.valueBuf
valueLen := uint32(len(vbuf))
err := regEnumValue(k, index, &nbuf[0], &nameLen, nil, &valueType, &vbuf[0], &valueLen)
switch err {
case windows.ERROR_NO_MORE_ITEMS:
break loopValues
case windows.ERROR_MORE_DATA:
bufs.valueBuf = make([]byte, valueLen)
continue
case nil:
default:
return fmt.Errorf("regEnumValue: %w", err)
}
var value any
switch valueType {
case registry.SZ, registry.EXPAND_SZ:
value = windows.UTF16PtrToString((*uint16)(unsafe.Pointer(&vbuf[0])))
case registry.BINARY:
if valueLen > maxBinaryValueLen {
valueLen = maxBinaryValueLen
}
value = append([]byte{}, vbuf[:valueLen]...)
case registry.DWORD:
value = binary.LittleEndian.Uint32(vbuf[:4])
case registry.MULTI_SZ:
// Adapted from x/sys/windows/registry/(Key).GetStringsValue
p := (*[1 << 29]uint16)(unsafe.Pointer(&vbuf[0]))[: valueLen/2 : valueLen/2]
var strs []string
if len(p) > 0 {
if p[len(p)-1] == 0 {
p = p[:len(p)-1]
}
strs = make([]string, 0, 5)
from := 0
for i, c := range p {
if c == 0 {
strs = append(strs, string(utf16.Decode(p[from:i])))
from = i + 1
}
}
}
value = strs
case registry.QWORD:
value = binary.LittleEndian.Uint64(vbuf[:8])
default:
value = fmt.Sprintf("<unsupported value type %d>", valueType)
}
kv[windows.UTF16PtrToString(&nbuf[0])] = value
index++
}
if recursionLimit > 0 {
if sks, err := k.ReadSubKeyNames(0); err == nil {
for _, sk := range sks {
if err := getRegSubKey(k, sk, recursionLimit-1, bufs, kv); err != nil {
return err
}
}
}
}
output[keyStr] = kv
return nil
}
type moduleInfo struct {
path string `json:"-"` // internal use only
BaseAddress uintptr `json:"baseAddress"`
Size uint32 `json:"size"`
DebugInfo map[string]string `json:"debugInfo,omitempty"` // map for JSON marshaling purposes
DebugInfoErr error `json:"debugInfoErr,omitempty"`
Signature map[string]string `json:"signature,omitempty"` // map for JSON marshaling purposes
SignatureErr error `json:"signatureErr,omitempty"`
VersionInfo map[string]string `json:"versionInfo,omitempty"` // map for JSON marshaling purposes
VersionErr error `json:"versionErr,omitempty"`
}
func (mi *moduleInfo) setVersionInfo() {
vi, err := pe.NewVersionInfo(mi.path)
if err != nil {
if !errors.Is(err, pe.ErrNotPresent) {
mi.VersionErr = err
}
return
}
info := map[string]string{
"": vi.VersionNumber().String(),
}
ci, err := vi.Field("CompanyName")
if err == nil {
info["companyName"] = ci
}
mi.VersionInfo = info
}
var errAssertingType = errors.New("asserting DataDirectory type")
func (mi *moduleInfo) setDebugInfo(base uintptr, size uint32) {
pem, err := pe.NewPEFromBaseAddressAndSize(base, size)
if err != nil {
mi.DebugInfoErr = err
return
}
defer pem.Close()
debugDirAny, err := pem.DataDirectoryEntry(pe.IMAGE_DIRECTORY_ENTRY_DEBUG)
if err != nil {
if !errors.Is(err, pe.ErrNotPresent) {
mi.DebugInfoErr = err
}
return
}
debugDir, ok := debugDirAny.([]pe.IMAGE_DEBUG_DIRECTORY)
if !ok {
mi.DebugInfoErr = errAssertingType
return
}
for _, dde := range debugDir {
if dde.Type != pe.IMAGE_DEBUG_TYPE_CODEVIEW {
continue
}
cv, err := pem.ExtractCodeViewInfo(dde)
if err == nil {
mi.DebugInfo = map[string]string{
"id": cv.String(),
"pdb": strings.ToLower(filepath.Base(cv.PDBPath)),
}
} else {
mi.DebugInfoErr = err
}
return
}
}
func (mi *moduleInfo) setAuthenticodeInfo() {
certSubject, provenance, err := authenticode.QueryCertSubject(mi.path)
if err != nil {
if !errors.Is(err, authenticode.ErrSigNotFound) {
mi.SignatureErr = err
}
return
}
sigInfo := map[string]string{
"subject": certSubject,
}
switch provenance {
case authenticode.SigProvEmbedded:
sigInfo["provenance"] = "embedded"
case authenticode.SigProvCatalog:
sigInfo["provenance"] = "catalog"
default:
}
mi.Signature = sigInfo
}
func getModuleInfo() (map[string]moduleInfo, error) {
// Take a snapshot of all modules currently loaded into the current process
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPMODULE, 0)
if err != nil {
return nil, err
}
defer windows.CloseHandle(snap)
result := make(map[string]moduleInfo)
me := windows.ModuleEntry32{
Size: uint32(unsafe.Sizeof(windows.ModuleEntry32{})),
}
// Now walk the list
for merr := windows.Module32First(snap, &me); merr == nil; merr = windows.Module32Next(snap, &me) {
name := strings.ToLower(windows.UTF16ToString(me.Module[:]))
path := windows.UTF16ToString(me.ExePath[:])
base := me.ModBaseAddr
size := me.ModBaseSize
entry := moduleInfo{
path: path,
BaseAddress: base,
Size: size,
}
entry.setVersionInfo()
entry.setDebugInfo(base, size)
entry.setAuthenticodeInfo()
result[name] = entry
}
return result, nil
}

View File

@@ -0,0 +1,128 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package osdiag
import (
"errors"
"fmt"
"strings"
"testing"
"golang.org/x/exp/maps"
"golang.org/x/sys/windows/registry"
)
func makeLongBinaryValue() []byte {
buf := make([]byte, maxBinaryValueLen*2)
for i, _ := range buf {
buf[i] = byte(i % 0xFF)
}
return buf
}
var testData = map[string]any{
"": "I am the default",
"StringEmpty": "",
"StringShort": "Hello",
"StringLong": strings.Repeat("7", initialValueBufLen+1),
"MultiStringEmpty": []string{},
"MultiStringSingle": []string{"Foo"},
"MultiStringSingleEmpty": []string{""},
"MultiString": []string{"Foo", "Bar", "Baz"},
"MultiStringWithEmptyBeginning": []string{"", "Foo", "Bar"},
"MultiStringWithEmptyMiddle": []string{"Foo", "", "Bar"},
"MultiStringWithEmptyEnd": []string{"Foo", "Bar", ""},
"DWord": uint32(0x12345678),
"QWord": uint64(0x123456789abcdef0),
"BinaryEmpty": []byte{},
"BinaryShort": []byte{0x01, 0x02, 0x03, 0x04},
"BinaryLong": makeLongBinaryValue(),
}
const (
keyNameTest = `SOFTWARE\Tailscale Test`
subKeyNameTest = "SubKey"
)
func setValues(t *testing.T, k registry.Key) {
for vk, v := range testData {
var err error
switch tv := v.(type) {
case string:
err = k.SetStringValue(vk, tv)
case []string:
err = k.SetStringsValue(vk, tv)
case uint32:
err = k.SetDWordValue(vk, tv)
case uint64:
err = k.SetQWordValue(vk, tv)
case []byte:
err = k.SetBinaryValue(vk, tv)
default:
t.Fatalf("Unknown type")
}
if err != nil {
t.Fatalf("Error setting %q: %v", vk, err)
}
}
}
func TestRegistrySupportInfo(t *testing.T) {
// Make sure the key doesn't exist yet
k, err := registry.OpenKey(registry.CURRENT_USER, keyNameTest, registry.READ)
switch {
case err == nil:
k.Close()
t.Fatalf("Test key already exists")
case !errors.Is(err, registry.ErrNotExist):
t.Fatal(err)
}
func() {
k, _, err := registry.CreateKey(registry.CURRENT_USER, keyNameTest, registry.WRITE)
if err != nil {
t.Fatalf("Error creating test key: %v", err)
}
defer k.Close()
setValues(t, k)
sk, _, err := registry.CreateKey(k, subKeyNameTest, registry.WRITE)
if err != nil {
t.Fatalf("Error creating test subkey: %v", err)
}
defer sk.Close()
setValues(t, sk)
}()
t.Cleanup(func() {
registry.DeleteKey(registry.CURRENT_USER, keyNameTest+"\\"+subKeyNameTest)
registry.DeleteKey(registry.CURRENT_USER, keyNameTest)
})
wantValuesData := maps.Clone(testData)
wantValuesData["BinaryLong"] = (wantValuesData["BinaryLong"].([]byte))[:maxBinaryValueLen]
wantKeyData := make(map[string]any)
maps.Copy(wantKeyData, wantValuesData)
wantSubKeyData := make(map[string]any)
maps.Copy(wantSubKeyData, wantValuesData)
wantKeyData[subKeyNameTest] = wantSubKeyData
wantData := map[string]any{
"HKCU\\" + keyNameTest: wantKeyData,
}
gotData, err := getRegistrySupportInfo(registry.CURRENT_USER, []string{keyNameTest})
if err != nil {
t.Errorf("getRegistrySupportInfo error: %v", err)
}
want, got := fmt.Sprintf("%#v", wantData), fmt.Sprintf("%#v", gotData)
if want != got {
t.Errorf("Compare error: want\n%s,\ngot %s", want, got)
}
}

View File

@@ -0,0 +1,53 @@
// Code generated by 'go generate'; DO NOT EDIT.
package osdiag
import (
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
var _ unsafe.Pointer
// Do the interface allocations only once for common
// Errno values.
const (
errnoERROR_IO_PENDING = 997
)
var (
errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING)
errERROR_EINVAL error = syscall.EINVAL
)
// errnoErr returns common boxed Errno values, to prevent
// allocations at runtime.
func errnoErr(e syscall.Errno) error {
switch e {
case 0:
return errERROR_EINVAL
case errnoERROR_IO_PENDING:
return errERROR_IO_PENDING
}
// TODO: add more here, after collecting data on the common
// error values see on Windows. (perhaps when running
// all.bat?)
return e
}
var (
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
procRegEnumValueW = modadvapi32.NewProc("RegEnumValueW")
)
func regEnumValue(key registry.Key, index uint32, valueName *uint16, valueNameLen *uint32, reserved *uint32, valueType *uint32, pData *byte, cbData *uint32) (ret error) {
r0, _, _ := syscall.Syscall9(procRegEnumValueW.Addr(), 8, uintptr(key), uintptr(index), uintptr(unsafe.Pointer(valueName)), uintptr(unsafe.Pointer(valueNameLen)), uintptr(unsafe.Pointer(reserved)), uintptr(unsafe.Pointer(valueType)), uintptr(unsafe.Pointer(pData)), uintptr(unsafe.Pointer(cbData)), 0)
if r0 != 0 {
ret = syscall.Errno(r0)
}
return
}

View File

@@ -0,0 +1,515 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package authenticode
import (
"encoding/hex"
"errors"
"fmt"
"path/filepath"
"strings"
"unsafe"
"github.com/dblohm7/wingoes"
"github.com/dblohm7/wingoes/pe"
"golang.org/x/sys/windows"
)
var (
// ErrSigNotFound is returned if no authenticode signature could be found.
ErrSigNotFound = errors.New("authenticode signature not found")
// ErrUnexpectedCertSubject is wrapped with the actual cert subject and
// returned when the binary is signed by a different subject than expected.
ErrUnexpectedCertSubject = errors.New("unexpected cert subject")
errCertSubjectNotFound = errors.New("cert subject not found")
errCertSubjectDecodeLenMismatch = errors.New("length mismatch while decoding cert subject")
)
const (
_CERT_STRONG_SIGN_OID_INFO_CHOICE = 2
_CMSG_SIGNER_CERT_INFO_PARAM = 7
_MSI_INVALID_HASH_IS_FATAL = 1
_TRUST_E_NOSIGNATURE = wingoes.HRESULT(-((0x800B0100 ^ 0xFFFFFFFF) + 1))
)
// Verify performs authenticode verification on the file at path, and also
// ensures that expectedCertSubject was the entity who signed it. path may point
// to either a PE binary or an MSI package. ErrSigNotFound is returned if no
// signature is found.
func Verify(path string, expectedCertSubject string) error {
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
var subject string
if strings.EqualFold(filepath.Ext(path), ".msi") {
subject, err = verifyMSI(path16)
} else {
subject, _, err = queryPE(path16, true)
}
if err != nil {
return err
}
if subject != expectedCertSubject {
return fmt.Errorf("%w %q", ErrUnexpectedCertSubject, subject)
}
return nil
}
// SigProvenance indicates whether an authenticode signature was embedded within
// the file itself, or the signature applies to an associated catalog file.
type SigProvenance int
const (
SigProvUnknown = SigProvenance(iota)
SigProvEmbedded
SigProvCatalog
)
// QueryCertSubject obtains the subject associated with the certificate used to
// sign the PE binary located at path. When err == nil, it also returns the
// provenance of that signature. ErrSigNotFound is returned if no signature
// is found. Note that this function does *not* validate the chain of trust; use
// Verify for that purpose!
func QueryCertSubject(path string) (certSubject string, provenance SigProvenance, err error) {
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return "", SigProvUnknown, err
}
return queryPE(path16, false)
}
func queryPE(utf16Path *uint16, verify bool) (string, SigProvenance, error) {
certSubject, err := queryEmbeddedCertSubject(utf16Path, verify)
switch {
case err == ErrSigNotFound:
// Try looking for the signature in a catalog file.
default:
return certSubject, SigProvEmbedded, err
}
certSubject, err = queryCatalogCertSubject(utf16Path, verify)
switch {
case err == ErrSigNotFound:
return "", SigProvUnknown, err
default:
return certSubject, SigProvCatalog, err
}
}
type CertSubjectError struct {
Err error
Subject string
}
func (e *CertSubjectError) Error() string {
if e == nil {
return "<nil>"
}
if e.Subject == "" {
return e.Err.Error()
}
return fmt.Sprintf("cert subject %q: %v", e.Subject, e.Err)
}
func (e *CertSubjectError) Unwrap() error {
return e.Err
}
func verifyMSI(path *uint16) (string, error) {
var certCtx *windows.CertContext
hr := msiGetFileSignatureInformation(path, _MSI_INVALID_HASH_IS_FATAL, &certCtx, nil, nil)
if e := wingoes.ErrorFromHRESULT(hr); e.Failed() {
if e == wingoes.ErrorFromHRESULT(_TRUST_E_NOSIGNATURE) {
return "", ErrSigNotFound
}
return "", e
}
defer windows.CertFreeCertificateContext(certCtx)
return certSubjectFromCertContext(certCtx)
}
func certSubjectFromCertContext(certCtx *windows.CertContext) (string, error) {
desiredLen := windows.CertGetNameString(
certCtx,
windows.CERT_NAME_SIMPLE_DISPLAY_TYPE,
0,
nil,
nil,
0,
)
if desiredLen <= 1 {
return "", errCertSubjectNotFound
}
buf := make([]uint16, desiredLen)
actualLen := windows.CertGetNameString(
certCtx,
windows.CERT_NAME_SIMPLE_DISPLAY_TYPE,
0,
nil,
&buf[0],
desiredLen,
)
if actualLen != desiredLen {
return "", errCertSubjectDecodeLenMismatch
}
return windows.UTF16ToString(buf), nil
}
type objectQuery struct {
certStore windows.Handle
cryptMsg windows.Handle
encodingType uint32
}
func newObjectQuery(utf16Path *uint16) (*objectQuery, error) {
var oq objectQuery
if err := windows.CryptQueryObject(
windows.CERT_QUERY_OBJECT_FILE,
unsafe.Pointer(utf16Path),
windows.CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED,
windows.CERT_QUERY_FORMAT_FLAG_BINARY,
0,
&oq.encodingType,
nil,
nil,
&oq.certStore,
&oq.cryptMsg,
nil,
); err != nil {
return nil, err
}
return &oq, nil
}
func (oq *objectQuery) Close() error {
if oq.certStore != 0 {
if err := windows.CertCloseStore(oq.certStore, 0); err != nil {
return err
}
oq.certStore = 0
}
if oq.cryptMsg != 0 {
if err := cryptMsgClose(oq.cryptMsg); err != nil {
return err
}
oq.cryptMsg = 0
}
return nil
}
func (oq *objectQuery) certSubject() (string, error) {
var certInfoLen uint32
if err := cryptMsgGetParam(
oq.cryptMsg,
_CMSG_SIGNER_CERT_INFO_PARAM,
0,
unsafe.Pointer(nil),
&certInfoLen,
); err != nil {
return "", err
}
buf := make([]byte, certInfoLen)
if err := cryptMsgGetParam(
oq.cryptMsg,
_CMSG_SIGNER_CERT_INFO_PARAM,
0,
unsafe.Pointer(&buf[0]),
&certInfoLen,
); err != nil {
return "", err
}
certInfo := (*windows.CertInfo)(unsafe.Pointer(&buf[0]))
certCtx, err := windows.CertFindCertificateInStore(
oq.certStore,
oq.encodingType,
0,
windows.CERT_FIND_SUBJECT_CERT,
unsafe.Pointer(certInfo),
nil,
)
if err != nil {
return "", err
}
defer windows.CertFreeCertificateContext(certCtx)
return certSubjectFromCertContext(certCtx)
}
func extractCertBlob(hfile windows.Handle) ([]byte, error) {
pef, err := pe.NewPEFromFileHandle(hfile)
if err != nil {
return nil, err
}
defer pef.Close()
certsAny, err := pef.DataDirectoryEntry(pe.IMAGE_DIRECTORY_ENTRY_SECURITY)
if err != nil {
if errors.Is(err, pe.ErrNotPresent) {
err = ErrSigNotFound
}
return nil, err
}
certs, ok := certsAny.([]pe.AuthenticodeCert)
if !ok || len(certs) == 0 {
return nil, ErrSigNotFound
}
for _, cert := range certs {
if cert.Revision() != pe.WIN_CERT_REVISION_2_0 || cert.Type() != pe.WIN_CERT_TYPE_PKCS_SIGNED_DATA {
continue
}
return cert.Data(), nil
}
return nil, ErrSigNotFound
}
type _HCRYPTPROV windows.Handle
type _CRYPT_VERIFY_MESSAGE_PARA struct {
CBSize uint32
MsgAndCertEncodingType uint32
HCryptProv _HCRYPTPROV
FNGetSignerCertificate uintptr
GetArg uintptr
StrongSignPara *windows.CertStrongSignPara
}
func querySubjectFromBlob(blob []byte) (string, error) {
para := _CRYPT_VERIFY_MESSAGE_PARA{
CBSize: uint32(unsafe.Sizeof(_CRYPT_VERIFY_MESSAGE_PARA{})),
MsgAndCertEncodingType: windows.X509_ASN_ENCODING | windows.PKCS_7_ASN_ENCODING,
}
var certCtx *windows.CertContext
if err := cryptVerifyMessageSignature(&para, 0, &blob[0], uint32(len(blob)), nil, nil, &certCtx); err != nil {
return "", err
}
defer windows.CertFreeCertificateContext(certCtx)
return certSubjectFromCertContext(certCtx)
}
func queryEmbeddedCertSubject(utf16Path *uint16, verify bool) (string, error) {
peBinary, err := windows.CreateFile(
utf16Path,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
0,
0,
)
if err != nil {
return "", err
}
defer windows.CloseHandle(peBinary)
blob, err := extractCertBlob(peBinary)
if err != nil {
return "", err
}
certSubj, err := querySubjectFromBlob(blob)
if err != nil {
return "", err
}
if !verify {
return certSubj, nil
}
wintrustArg := unsafe.Pointer(&windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: utf16Path,
File: peBinary,
})
if err := verifyTrust(windows.WTD_CHOICE_FILE, wintrustArg); err != nil {
// We might still want to know who the cert subject claims to be
// even if the validation has failed (eg for troubleshooting purposes),
// so we return a CertSubjectError.
return "", &CertSubjectError{Err: err, Subject: certSubj}
}
return certSubj, nil
}
var (
_BCRYPT_SHA256_ALGORITHM = &([]uint16{'S', 'H', 'A', '2', '5', '6', 0})[0]
_OID_CERT_STRONG_SIGN_OS_1 = &([]byte("1.3.6.1.4.1.311.72.1.1\x00"))[0]
)
type _HCATADMIN windows.Handle
type _HCATINFO windows.Handle
type _CATALOG_INFO struct {
size uint32
catalogFile [windows.MAX_PATH]uint16
}
type _WINTRUST_CATALOG_INFO struct {
size uint32
catalogVersion uint32
catalogFilePath *uint16
memberTag *uint16
memberFilePath *uint16
memberFile windows.Handle
pCalculatedFileHash *byte
cbCalculatedFileHash uint32
catalogContext uintptr
catAdmin _HCATADMIN
}
func queryCatalogCertSubject(utf16Path *uint16, verify bool) (string, error) {
var catAdmin _HCATADMIN
policy := windows.CertStrongSignPara{
Size: uint32(unsafe.Sizeof(windows.CertStrongSignPara{})),
InfoChoice: _CERT_STRONG_SIGN_OID_INFO_CHOICE,
InfoOrSerializedInfoOrOID: unsafe.Pointer(_OID_CERT_STRONG_SIGN_OS_1),
}
if err := cryptCATAdminAcquireContext2(
&catAdmin,
nil,
_BCRYPT_SHA256_ALGORITHM,
&policy,
0,
); err != nil {
return "", err
}
defer cryptCATAdminReleaseContext(catAdmin, 0)
// We use windows.CreateFile instead of standard library facilities because:
// 1. Subsequent API calls directly utilize the file's Win32 HANDLE;
// 2. We're going to be hashing the contents of this file, so we want to
// provide a sequential-scan hint to the kernel.
memberFile, err := windows.CreateFile(
utf16Path,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return "", err
}
defer windows.CloseHandle(memberFile)
var hashLen uint32
if err := cryptCATAdminCalcHashFromFileHandle2(
catAdmin,
memberFile,
&hashLen,
nil,
0,
); err != nil {
return "", err
}
hashBuf := make([]byte, hashLen)
if err := cryptCATAdminCalcHashFromFileHandle2(
catAdmin,
memberFile,
&hashLen,
&hashBuf[0],
0,
); err != nil {
return "", err
}
catInfoCtx, err := cryptCATAdminEnumCatalogFromHash(
catAdmin,
&hashBuf[0],
hashLen,
0,
nil,
)
if err != nil {
if err == windows.ERROR_NOT_FOUND {
err = ErrSigNotFound
}
return "", err
}
defer cryptCATAdminReleaseCatalogContext(catAdmin, catInfoCtx, 0)
catInfo := _CATALOG_INFO{
size: uint32(unsafe.Sizeof(_CATALOG_INFO{})),
}
if err := cryptCATAdminCatalogInfoFromContext(catInfoCtx, &catInfo, 0); err != nil {
return "", err
}
oq, err := newObjectQuery(&catInfo.catalogFile[0])
if err != nil {
return "", err
}
defer oq.Close()
certSubj, err := oq.certSubject()
if err != nil {
return "", err
}
if !verify {
return certSubj, nil
}
// memberTag is required to be formatted this way.
hbh := strings.ToUpper(hex.EncodeToString(hashBuf))
memberTag, err := windows.UTF16PtrFromString(hbh)
if err != nil {
return "", err
}
wintrustArg := unsafe.Pointer(&_WINTRUST_CATALOG_INFO{
size: uint32(unsafe.Sizeof(_WINTRUST_CATALOG_INFO{})),
catalogFilePath: &catInfo.catalogFile[0],
memberTag: memberTag,
memberFilePath: utf16Path,
memberFile: memberFile,
catAdmin: catAdmin,
})
if err := verifyTrust(windows.WTD_CHOICE_CATALOG, wintrustArg); err != nil {
// We might still want to know who the cert subject claims to be
// even if the validation has failed (eg for troubleshooting purposes),
// so we return a CertSubjectError.
return "", &CertSubjectError{Err: err, Subject: certSubj}
}
return certSubj, nil
}
func verifyTrust(infoType uint32, info unsafe.Pointer) error {
data := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_WHOLECHAIN, // Full revocation checking, as this is called with network connectivity.
UnionChoice: infoType,
StateAction: windows.WTD_STATEACTION_VERIFY,
FileOrCatalogOrBlobOrSgnrOrCert: info,
}
err := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
data.StateAction = windows.WTD_STATEACTION_CLOSE
windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
return err
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package authenticode
//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go mksyscall.go
//go:generate go run golang.org/x/tools/cmd/goimports -w zsyscall_windows.go
//sys cryptCATAdminAcquireContext2(hCatAdmin *_HCATADMIN, pgSubsystem *windows.GUID, hashAlgorithm *uint16, strongHashPolicy *windows.CertStrongSignPara, flags uint32) (err error) [int32(failretval)==0] = wintrust.CryptCATAdminAcquireContext2
//sys cryptCATAdminCalcHashFromFileHandle2(hCatAdmin _HCATADMIN, file windows.Handle, pcbHash *uint32, pbHash *byte, flags uint32) (err error) [int32(failretval)==0] = wintrust.CryptCATAdminCalcHashFromFileHandle2
//sys cryptCATAdminCatalogInfoFromContext(hCatInfo _HCATINFO, catInfo *_CATALOG_INFO, flags uint32) (err error) [int32(failretval)==0] = wintrust.CryptCATCatalogInfoFromContext
//sys cryptCATAdminEnumCatalogFromHash(hCatAdmin _HCATADMIN, pbHash *byte, cbHash uint32, flags uint32, prevCatInfo *_HCATINFO) (ret _HCATINFO, err error) [ret==0] = wintrust.CryptCATAdminEnumCatalogFromHash
//sys cryptCATAdminReleaseCatalogContext(hCatAdmin _HCATADMIN, hCatInfo _HCATINFO, flags uint32) (err error) [int32(failretval)==0] = wintrust.CryptCATAdminReleaseCatalogContext
//sys cryptCATAdminReleaseContext(hCatAdmin _HCATADMIN, flags uint32) (err error) [int32(failretval)==0] = wintrust.CryptCATAdminReleaseContext
//sys cryptMsgClose(cryptMsg windows.Handle) (err error) [int32(failretval)==0] = crypt32.CryptMsgClose
//sys cryptMsgGetParam(cryptMsg windows.Handle, paramType uint32, index uint32, data unsafe.Pointer, dataLen *uint32) (err error) [int32(failretval)==0] = crypt32.CryptMsgGetParam
//sys cryptVerifyMessageSignature(pVerifyPara *_CRYPT_VERIFY_MESSAGE_PARA, signerIndex uint32, pbSignedBlob *byte, cbSignedBlob uint32, pbDecoded *byte, pdbDecoded *uint32, ppSignerCert **windows.CertContext) (err error) [int32(failretval)==0] = crypt32.CryptVerifyMessageSignature
//sys msiGetFileSignatureInformation(signedObjectPath *uint16, flags uint32, certCtx **windows.CertContext, pbHashData *byte, cbHashData *uint32) (ret wingoes.HRESULT) = msi.MsiGetFileSignatureInformationW

View File

@@ -0,0 +1,135 @@
// Code generated by 'go generate'; DO NOT EDIT.
package authenticode
import (
"syscall"
"unsafe"
"github.com/dblohm7/wingoes"
"golang.org/x/sys/windows"
)
var _ unsafe.Pointer
// Do the interface allocations only once for common
// Errno values.
const (
errnoERROR_IO_PENDING = 997
)
var (
errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING)
errERROR_EINVAL error = syscall.EINVAL
)
// errnoErr returns common boxed Errno values, to prevent
// allocations at runtime.
func errnoErr(e syscall.Errno) error {
switch e {
case 0:
return errERROR_EINVAL
case errnoERROR_IO_PENDING:
return errERROR_IO_PENDING
}
// TODO: add more here, after collecting data on the common
// error values see on Windows. (perhaps when running
// all.bat?)
return e
}
var (
modcrypt32 = windows.NewLazySystemDLL("crypt32.dll")
modmsi = windows.NewLazySystemDLL("msi.dll")
modwintrust = windows.NewLazySystemDLL("wintrust.dll")
procCryptMsgClose = modcrypt32.NewProc("CryptMsgClose")
procCryptMsgGetParam = modcrypt32.NewProc("CryptMsgGetParam")
procCryptVerifyMessageSignature = modcrypt32.NewProc("CryptVerifyMessageSignature")
procMsiGetFileSignatureInformationW = modmsi.NewProc("MsiGetFileSignatureInformationW")
procCryptCATAdminAcquireContext2 = modwintrust.NewProc("CryptCATAdminAcquireContext2")
procCryptCATAdminCalcHashFromFileHandle2 = modwintrust.NewProc("CryptCATAdminCalcHashFromFileHandle2")
procCryptCATAdminEnumCatalogFromHash = modwintrust.NewProc("CryptCATAdminEnumCatalogFromHash")
procCryptCATAdminReleaseCatalogContext = modwintrust.NewProc("CryptCATAdminReleaseCatalogContext")
procCryptCATAdminReleaseContext = modwintrust.NewProc("CryptCATAdminReleaseContext")
procCryptCATCatalogInfoFromContext = modwintrust.NewProc("CryptCATCatalogInfoFromContext")
)
func cryptMsgClose(cryptMsg windows.Handle) (err error) {
r1, _, e1 := syscall.Syscall(procCryptMsgClose.Addr(), 1, uintptr(cryptMsg), 0, 0)
if int32(r1) == 0 {
err = errnoErr(e1)
}
return
}
func cryptMsgGetParam(cryptMsg windows.Handle, paramType uint32, index uint32, data unsafe.Pointer, dataLen *uint32) (err error) {
r1, _, e1 := syscall.Syscall6(procCryptMsgGetParam.Addr(), 5, uintptr(cryptMsg), uintptr(paramType), uintptr(index), uintptr(data), uintptr(unsafe.Pointer(dataLen)), 0)
if int32(r1) == 0 {
err = errnoErr(e1)
}
return
}
func cryptVerifyMessageSignature(pVerifyPara *_CRYPT_VERIFY_MESSAGE_PARA, signerIndex uint32, pbSignedBlob *byte, cbSignedBlob uint32, pbDecoded *byte, pdbDecoded *uint32, ppSignerCert **windows.CertContext) (err error) {
r1, _, e1 := syscall.Syscall9(procCryptVerifyMessageSignature.Addr(), 7, uintptr(unsafe.Pointer(pVerifyPara)), uintptr(signerIndex), uintptr(unsafe.Pointer(pbSignedBlob)), uintptr(cbSignedBlob), uintptr(unsafe.Pointer(pbDecoded)), uintptr(unsafe.Pointer(pdbDecoded)), uintptr(unsafe.Pointer(ppSignerCert)), 0, 0)
if int32(r1) == 0 {
err = errnoErr(e1)
}
return
}
func msiGetFileSignatureInformation(signedObjectPath *uint16, flags uint32, certCtx **windows.CertContext, pbHashData *byte, cbHashData *uint32) (ret wingoes.HRESULT) {
r0, _, _ := syscall.Syscall6(procMsiGetFileSignatureInformationW.Addr(), 5, uintptr(unsafe.Pointer(signedObjectPath)), uintptr(flags), uintptr(unsafe.Pointer(certCtx)), uintptr(unsafe.Pointer(pbHashData)), uintptr(unsafe.Pointer(cbHashData)), 0)
ret = wingoes.HRESULT(r0)
return
}
func cryptCATAdminAcquireContext2(hCatAdmin *_HCATADMIN, pgSubsystem *windows.GUID, hashAlgorithm *uint16, strongHashPolicy *windows.CertStrongSignPara, flags uint32) (err error) {
r1, _, e1 := syscall.Syscall6(procCryptCATAdminAcquireContext2.Addr(), 5, uintptr(unsafe.Pointer(hCatAdmin)), uintptr(unsafe.Pointer(pgSubsystem)), uintptr(unsafe.Pointer(hashAlgorithm)), uintptr(unsafe.Pointer(strongHashPolicy)), uintptr(flags), 0)
if int32(r1) == 0 {
err = errnoErr(e1)
}
return
}
func cryptCATAdminCalcHashFromFileHandle2(hCatAdmin _HCATADMIN, file windows.Handle, pcbHash *uint32, pbHash *byte, flags uint32) (err error) {
r1, _, e1 := syscall.Syscall6(procCryptCATAdminCalcHashFromFileHandle2.Addr(), 5, uintptr(hCatAdmin), uintptr(file), uintptr(unsafe.Pointer(pcbHash)), uintptr(unsafe.Pointer(pbHash)), uintptr(flags), 0)
if int32(r1) == 0 {
err = errnoErr(e1)
}
return
}
func cryptCATAdminEnumCatalogFromHash(hCatAdmin _HCATADMIN, pbHash *byte, cbHash uint32, flags uint32, prevCatInfo *_HCATINFO) (ret _HCATINFO, err error) {
r0, _, e1 := syscall.Syscall6(procCryptCATAdminEnumCatalogFromHash.Addr(), 5, uintptr(hCatAdmin), uintptr(unsafe.Pointer(pbHash)), uintptr(cbHash), uintptr(flags), uintptr(unsafe.Pointer(prevCatInfo)), 0)
ret = _HCATINFO(r0)
if ret == 0 {
err = errnoErr(e1)
}
return
}
func cryptCATAdminReleaseCatalogContext(hCatAdmin _HCATADMIN, hCatInfo _HCATINFO, flags uint32) (err error) {
r1, _, e1 := syscall.Syscall(procCryptCATAdminReleaseCatalogContext.Addr(), 3, uintptr(hCatAdmin), uintptr(hCatInfo), uintptr(flags))
if int32(r1) == 0 {
err = errnoErr(e1)
}
return
}
func cryptCATAdminReleaseContext(hCatAdmin _HCATADMIN, flags uint32) (err error) {
r1, _, e1 := syscall.Syscall(procCryptCATAdminReleaseContext.Addr(), 2, uintptr(hCatAdmin), uintptr(flags), 0)
if int32(r1) == 0 {
err = errnoErr(e1)
}
return
}
func cryptCATAdminCatalogInfoFromContext(hCatInfo _HCATINFO, catInfo *_CATALOG_INFO, flags uint32) (err error) {
r1, _, e1 := syscall.Syscall(procCryptCATCatalogInfoFromContext.Addr(), 3, uintptr(hCatInfo), uintptr(unsafe.Pointer(catInfo)), uintptr(flags))
if int32(r1) == 0 {
err = errnoErr(e1)
}
return
}

View File

@@ -7,4 +7,3 @@ package winutil
//go:generate go run golang.org/x/tools/cmd/goimports -w zsyscall_windows.go
//sys queryServiceConfig2(hService windows.Handle, infoLevel uint32, buf *byte, bufLen uint32, bytesNeeded *uint32) (err error) [failretval==0] = advapi32.QueryServiceConfig2W
//sys regEnumValue(key registry.Key, index uint32, valueName *uint16, valueNameLen *uint32, reserved *uint32, valueType *uint32, pData *byte, cbData *uint32) (ret error) [failretval!=0] = advapi32.RegEnumValueW

View File

@@ -4,11 +4,8 @@
package winutil
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os/exec"
"os/user"
@@ -16,12 +13,10 @@ import (
"strings"
"syscall"
"time"
"unicode/utf16"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"tailscale.com/types/logger"
)
const (
@@ -556,166 +551,3 @@ func findHomeDirInRegistry(uid string) (dir string, err error) {
}
return dir, nil
}
const (
maxBinaryValueLen = 128 // we'll truncate any binary values longer than this
maxRegValueNameLen = 16384 // maximum length supported by Windows + 1
initialValueBufLen = 80 // large enough to contain a stringified GUID encoded as UTF-16
)
const (
supportInfoKeyRegistry = "Registry"
)
// LogSupportInfo obtains information useful for troubleshooting and support,
// and writes it to the log as a JSON-encoded object.
func LogSupportInfo(logf logger.Logf) {
var b strings.Builder
if err := getSupportInfo(&b); err != nil {
log.Printf("error encoding support info: %v", err)
return
}
logf("Support Info: %s", b.String())
}
func getSupportInfo(w io.Writer) error {
output := make(map[string]any)
regInfo, err := getRegistrySupportInfo(registry.LOCAL_MACHINE, []string{regPolicyBase, regBase})
if err == nil {
output[supportInfoKeyRegistry] = regInfo
} else {
output[supportInfoKeyRegistry] = err
}
enc := json.NewEncoder(w)
return enc.Encode(output)
}
type getRegistrySupportInfoBufs struct {
nameBuf []uint16
valueBuf []byte
}
func getRegistrySupportInfo(root registry.Key, subKeys []string) (map[string]any, error) {
bufs := getRegistrySupportInfoBufs{
nameBuf: make([]uint16, maxRegValueNameLen),
valueBuf: make([]byte, initialValueBufLen),
}
output := make(map[string]any)
for _, subKey := range subKeys {
if err := getRegSubKey(root, subKey, 5, &bufs, output); err != nil && !errors.Is(err, registry.ErrNotExist) {
return nil, fmt.Errorf("getRegistrySupportInfo: %w", err)
}
}
return output, nil
}
func keyString(key registry.Key, subKey string) string {
var keyStr string
switch key {
case registry.CLASSES_ROOT:
keyStr = `HKCR\`
case registry.CURRENT_USER:
keyStr = `HKCU\`
case registry.LOCAL_MACHINE:
keyStr = `HKLM\`
case registry.USERS:
keyStr = `HKU\`
case registry.CURRENT_CONFIG:
keyStr = `HKCC\`
case registry.PERFORMANCE_DATA:
keyStr = `HKPD\`
default:
}
return keyStr + subKey
}
func getRegSubKey(key registry.Key, subKey string, recursionLimit int, bufs *getRegistrySupportInfoBufs, output map[string]any) error {
keyStr := keyString(key, subKey)
k, err := registry.OpenKey(key, subKey, registry.READ)
if err != nil {
return fmt.Errorf("opening %q: %w", keyStr, err)
}
defer k.Close()
kv := make(map[string]any)
index := uint32(0)
loopValues:
for {
nbuf := bufs.nameBuf
nameLen := uint32(len(nbuf))
valueType := uint32(0)
vbuf := bufs.valueBuf
valueLen := uint32(len(vbuf))
err := regEnumValue(k, index, &nbuf[0], &nameLen, nil, &valueType, &vbuf[0], &valueLen)
switch err {
case windows.ERROR_NO_MORE_ITEMS:
break loopValues
case windows.ERROR_MORE_DATA:
bufs.valueBuf = make([]byte, valueLen)
continue
case nil:
default:
return fmt.Errorf("regEnumValue: %w", err)
}
var value any
switch valueType {
case registry.SZ, registry.EXPAND_SZ:
value = windows.UTF16PtrToString((*uint16)(unsafe.Pointer(&vbuf[0])))
case registry.BINARY:
if valueLen > maxBinaryValueLen {
valueLen = maxBinaryValueLen
}
value = append([]byte{}, vbuf[:valueLen]...)
case registry.DWORD:
value = binary.LittleEndian.Uint32(vbuf[:4])
case registry.MULTI_SZ:
// Adapted from x/sys/windows/registry/(Key).GetStringsValue
p := (*[1 << 29]uint16)(unsafe.Pointer(&vbuf[0]))[: valueLen/2 : valueLen/2]
var strs []string
if len(p) > 0 {
if p[len(p)-1] == 0 {
p = p[:len(p)-1]
}
strs = make([]string, 0, 5)
from := 0
for i, c := range p {
if c == 0 {
strs = append(strs, string(utf16.Decode(p[from:i])))
from = i + 1
}
}
}
value = strs
case registry.QWORD:
value = binary.LittleEndian.Uint64(vbuf[:8])
default:
value = fmt.Sprintf("<unsupported value type %d>", valueType)
}
kv[windows.UTF16PtrToString(&nbuf[0])] = value
index++
}
if recursionLimit > 0 {
if sks, err := k.ReadSubKeyNames(0); err == nil {
for _, sk := range sks {
if err := getRegSubKey(k, sk, recursionLimit-1, bufs, kv); err != nil {
return err
}
}
}
}
output[keyStr] = kv
return nil
}

View File

@@ -4,13 +4,7 @@
package winutil
import (
"errors"
"fmt"
"strings"
"testing"
"golang.org/x/exp/maps"
"golang.org/x/sys/windows/registry"
)
const (
@@ -34,117 +28,3 @@ func TestLookupPseudoUser(t *testing.T) {
t.Errorf("LookupPseudoUser(%q) unexpectedly succeeded", networkSID)
}
}
func makeLongBinaryValue() []byte {
buf := make([]byte, maxBinaryValueLen*2)
for i, _ := range buf {
buf[i] = byte(i % 0xFF)
}
return buf
}
var testData = map[string]any{
"": "I am the default",
"StringEmpty": "",
"StringShort": "Hello",
"StringLong": strings.Repeat("7", initialValueBufLen+1),
"MultiStringEmpty": []string{},
"MultiStringSingle": []string{"Foo"},
"MultiStringSingleEmpty": []string{""},
"MultiString": []string{"Foo", "Bar", "Baz"},
"MultiStringWithEmptyBeginning": []string{"", "Foo", "Bar"},
"MultiStringWithEmptyMiddle": []string{"Foo", "", "Bar"},
"MultiStringWithEmptyEnd": []string{"Foo", "Bar", ""},
"DWord": uint32(0x12345678),
"QWord": uint64(0x123456789abcdef0),
"BinaryEmpty": []byte{},
"BinaryShort": []byte{0x01, 0x02, 0x03, 0x04},
"BinaryLong": makeLongBinaryValue(),
}
const (
keyNameTest = `SOFTWARE\Tailscale Test`
subKeyNameTest = "SubKey"
)
func setValues(t *testing.T, k registry.Key) {
for vk, v := range testData {
var err error
switch tv := v.(type) {
case string:
err = k.SetStringValue(vk, tv)
case []string:
err = k.SetStringsValue(vk, tv)
case uint32:
err = k.SetDWordValue(vk, tv)
case uint64:
err = k.SetQWordValue(vk, tv)
case []byte:
err = k.SetBinaryValue(vk, tv)
default:
t.Fatalf("Unknown type")
}
if err != nil {
t.Fatalf("Error setting %q: %v", vk, err)
}
}
}
func TestRegistrySupportInfo(t *testing.T) {
// Make sure the key doesn't exist yet
k, err := registry.OpenKey(registry.CURRENT_USER, keyNameTest, registry.READ)
switch {
case err == nil:
k.Close()
t.Fatalf("Test key already exists")
case !errors.Is(err, registry.ErrNotExist):
t.Fatal(err)
}
func() {
k, _, err := registry.CreateKey(registry.CURRENT_USER, keyNameTest, registry.WRITE)
if err != nil {
t.Fatalf("Error creating test key: %v", err)
}
defer k.Close()
setValues(t, k)
sk, _, err := registry.CreateKey(k, subKeyNameTest, registry.WRITE)
if err != nil {
t.Fatalf("Error creating test subkey: %v", err)
}
defer sk.Close()
setValues(t, sk)
}()
t.Cleanup(func() {
registry.DeleteKey(registry.CURRENT_USER, keyNameTest+"\\"+subKeyNameTest)
registry.DeleteKey(registry.CURRENT_USER, keyNameTest)
})
wantValuesData := maps.Clone(testData)
wantValuesData["BinaryLong"] = (wantValuesData["BinaryLong"].([]byte))[:maxBinaryValueLen]
wantKeyData := make(map[string]any)
maps.Copy(wantKeyData, wantValuesData)
wantSubKeyData := make(map[string]any)
maps.Copy(wantSubKeyData, wantValuesData)
wantKeyData[subKeyNameTest] = wantSubKeyData
wantData := map[string]any{
"HKCU\\" + keyNameTest: wantKeyData,
}
gotData, err := getRegistrySupportInfo(registry.CURRENT_USER, []string{keyNameTest})
if err != nil {
t.Errorf("getRegistrySupportInfo error: %v", err)
}
want, got := fmt.Sprintf("%#v", wantData), fmt.Sprintf("%#v", gotData)
if want != got {
t.Errorf("Compare error: want\n%s,\ngot %s", want, got)
}
}

View File

@@ -7,7 +7,6 @@ import (
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
var _ unsafe.Pointer
@@ -42,7 +41,6 @@ var (
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
procQueryServiceConfig2W = modadvapi32.NewProc("QueryServiceConfig2W")
procRegEnumValueW = modadvapi32.NewProc("RegEnumValueW")
)
func queryServiceConfig2(hService windows.Handle, infoLevel uint32, buf *byte, bufLen uint32, bytesNeeded *uint32) (err error) {
@@ -52,11 +50,3 @@ func queryServiceConfig2(hService windows.Handle, infoLevel uint32, buf *byte, b
}
return
}
func regEnumValue(key registry.Key, index uint32, valueName *uint16, valueNameLen *uint32, reserved *uint32, valueType *uint32, pData *byte, cbData *uint32) (ret error) {
r0, _, _ := syscall.Syscall9(procRegEnumValueW.Addr(), 8, uintptr(key), uintptr(index), uintptr(unsafe.Pointer(valueName)), uintptr(unsafe.Pointer(valueNameLen)), uintptr(unsafe.Pointer(reserved)), uintptr(unsafe.Pointer(valueType)), uintptr(unsafe.Pointer(pData)), uintptr(unsafe.Pointer(cbData)), 0)
if r0 != 0 {
ret = syscall.Errno(r0)
}
return
}

215
webui/index.html Normal file
View File

@@ -0,0 +1,215 @@
<!doctype html>
<html class="bg-gray-50">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
<title>Tailscale</title>
<link rel="stylesheet" href="/web.css">
<script type="module" src="/src/index.tsx"></script>
@vite(["src/web.ts"])
</head>
<body class="py-14">
<main class="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl" style="width: 95%">
<header class="flex justify-between items-center min-width-0 py-2 mb-8">
<svg width="26" height="26" viewBox="0 0 23 23" title="Tailscale" fill="none" xmlns="http://www.w3.org/2000/svg"
class="flex-shrink-0 mr-4">
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor"></circle>
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor"></circle>
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor"></circle>
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor"></circle>
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor"></circle>
</svg>
<div class="flex items-center justify-end space-x-2 w-2/3">
{{ with .Profile }}
<div class="text-right w-full leading-4">
<h4 class="truncate leading-normal">{{.LoginName}}</h4>
<div class="text-xs text-gray-500 text-right">
<a href="#" class="hover:text-gray-700 js-loginButton">Switch account</a> | <a href="#"
class="hover:text-gray-700 js-loginButton">Reauthenticate</a> | <a href="#"
class="hover:text-gray-700 js-logoutButton">Logout</a>
</div>
</div>
{{ end }}
<div class="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{{ with .Profile.ProfilePicURL }}
<div class="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style="background-image: url('{{.}}'); background-size: cover;"></div>
{{ else }}
<div class="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed"></div>
{{ end }}
</div>
</div>
</header>
{{ if .IP }}
<div
class="border border-gray-200 bg-gray-0 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
<div class="flex items-center min-width-0">
<svg class="flex-shrink-0 text-gray-600 mr-3 ml-1" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<div>
<h4 class="font-semibold truncate mr-2">{{.DeviceName}}</h4>
</div>
</div>
<h5>{{.IP}}</h5>
</div>
<p class="mt-1 ml-1 mb-6 text-xs text-gray-600">
Debug info: Tailscale {{ .IPNVersion }}, tun={{.TUNMode}}{{ if .IsSynology }}, DSM{{ .DSMVersion}}
{{if not .TUNMode}}
(<a href="https://tailscale.com/kb/1152/synology-outbound/" class="link-underline text-gray-600" target="_blank"
aria-label="Configure outbound synology traffic"
rel="noopener noreferrer">outgoing access not configured</a>)
{{end}}
{{end}}
</p>
{{ end }}
{{ if or (eq .Status "NeedsLogin") (eq .Status "NoState") }}
{{ if .IP }}
<div class="mb-6">
<p class="text-gray-700">Your device's key has expired. Reauthenticate this device by logging in again, or <a
href="https://tailscale.com/kb/1028/key-expiry" class="link" target="_blank">learn more</a>.</p>
</div>
<a href="#" class="mb-4 js-loginButton" target="_blank">
<button class="button button-blue w-full">Reauthenticate</button>
</a>
{{ else }}
<div class="mb-6">
<h3 class="text-3xl font-semibold mb-3">Log in</h3>
<p class="text-gray-700">Get started by logging in to your Tailscale network. Or,&nbsp;learn&nbsp;more at <a
href="https://tailscale.com/" class="link" target="_blank">tailscale.com</a>.</p>
</div>
<a href="#" class="mb-4 js-loginButton" target="_blank">
<button class="button button-blue w-full">Log In</button>
</a>
{{ end }}
{{ else if eq .Status "NeedsMachineAuth" }}
<div class="mb-4">
This device is authorized, but needs approval from a network admin before it can connect to the network.
</div>
{{ else }}
<div class="mb-4">
<p>You are connected! Access this device over Tailscale using the device name or IP address above.</p>
</div>
<div class="mb-4">
<a href="#" class="mb-4 js-advertiseExitNode">
{{if .AdvertiseExitNode}}
<button class="button button-red button-medium" id="enabled">Stop advertising Exit Node</button>
{{else}}
<button class="button button-blue button-medium" id="enabled">Advertise as Exit Node</button>
{{end}}
</a>
</div>
{{ end }}
</main>
<footer class="container max-w-lg mx-auto text-center">
<a class="text-xs text-gray-500 hover:text-gray-600" href="{{ .LicensesURL }}">Open Source Licenses</a>
</footer>
</body>
<script>
function() {
// TODO: link up to data
const advertiseExitNode = true;
const isUnraid = false;
const unraidCsrfToken = "csrfToken";
let fetchingUrl = false;
var data = {
AdvertiseRoutes: "1.1.1.1/24",
AdvertiseExitNode: advertiseExitNode,
Reauthenticate: false,
ForceLogout: false
};
function postData(e) {
e.preventDefault();
if (fetchingUrl) {
return;
}
fetchingUrl = true;
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get("SynoToken");
const nextParams = new URLSearchParams({ up: true });
if (token) {
nextParams.set("SynoToken", token)
}
const nextUrl = new URL(window.location);
nextUrl.search = nextParams.toString()
let body = JSON.stringify(data);
let contentType = "application/json";
if (isUnraid) {
const params = new URLSearchParams();
params.append("csrf_token", unraidCsrfToken);
params.append("ts_data", JSON.stringify(data));
body = params.toString();
contentType = "application/x-www-form-urlencoded;charset=UTF-8";
}
const url = nextUrl.toString();
fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
},
body: body
}).then(res => res.json()).then(res => {
fetchingUrl = false;
const err = res["error"];
if (err) {
throw new Error(err);
}
const url = res["url"];
if (url) {
if(isUnraid) {
window.open(url, "_blank");
} else {
document.location.href = url;
}
} else {
location.reload();
}
}).catch(err => {
alert("Failed operation: " + err.message);
});
}
document.querySelectorAll(".js-loginButton").forEach(function (el){
el.addEventListener("click", function(e) {
data.Reauthenticate = true;
postData(e);
});
})
document.querySelectorAll(".js-logoutButton").forEach(function(el) {
el.addEventListener("click", function (e) {
data.ForceLogout = true;
postData(e);
});
})
document.querySelectorAll(".js-advertiseExitNode").forEach(function (el) {
el.addEventListener("click", function(e) {
data.AdvertiseExitNode = !advertiseExitNode;
postData(e);
});
})
}()
</script>
</html>

30
webui/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "webui",
"version": "0.0.1",
"license": "BSD-3-Clause",
"engines": {
"node": "18.16.1",
"yarn": "1.22.19"
},
"private": true,
"dependencies": {
},
"devDependencies": {
"tailwindcss": "^3.1.6",
"typescript": "^4.7.4",
"vite": "^4.3.9",
"@vitejs/plugin-react-swc": "^3.3.2",
"vite-tsconfig-paths": "^3.5.0",
"vite-plugin-svgr": "^3.2.0",
"vite-plugin-rewrite-all": "^1.0.1"
},
"scripts": {
"build": "vite build",
"start": "vite",
"lint": "tsc --noEmit"
},
"prettier": {
"semi": false,
"printWidth": 80
}
}

1380
webui/public/web.css Normal file

File diff suppressed because it is too large Load Diff

3
webui/src/index.tsx Normal file
View File

@@ -0,0 +1,3 @@
const rootEl = document.createElement("div")
rootEl.textContent = "hello dev"
document.body.append(rootEl)

90
webui/src/web.ts Normal file
View File

@@ -0,0 +1,90 @@
export function run() {
const advertiseExitNode = {{ .AdvertiseExitNode }};
const isUnraid = {{ .IsUnraid }};
const unraidCsrfToken = "{{ .UnraidToken }}";
let fetchingUrl = false;
var data = {
AdvertiseRoutes: "{{ .AdvertiseRoutes }}",
AdvertiseExitNode: advertiseExitNode,
Reauthenticate: false,
ForceLogout: false
};
function postData(e) {
e.preventDefault();
if (fetchingUrl) {
return;
}
fetchingUrl = true;
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get("SynoToken");
const nextParams = new URLSearchParams({ up: true });
if (token) {
nextParams.set("SynoToken", token)
}
const nextUrl = new URL(window.location);
nextUrl.search = nextParams.toString()
let body = JSON.stringify(data);
let contentType = "application/json";
if (isUnraid) {
const params = new URLSearchParams();
params.append("csrf_token", unraidCsrfToken);
params.append("ts_data", JSON.stringify(data));
body = params.toString();
contentType = "application/x-www-form-urlencoded;charset=UTF-8";
}
const url = nextUrl.toString();
fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
},
body: body
}).then(res => res.json()).then(res => {
fetchingUrl = false;
const err = res["error"];
if (err) {
throw new Error(err);
}
const url = res["url"];
if (url) {
if(isUnraid) {
window.open(url, "_blank");
} else {
document.location.href = url;
}
} else {
location.reload();
}
}).catch(err => {
alert("Failed operation: " + err.message);
});
}
document.querySelectorAll(".js-loginButton").forEach(function (el){
el.addEventListener("click", function(e) {
data.Reauthenticate = true;
postData(e);
});
})
document.querySelectorAll(".js-logoutButton").forEach(function(el) {
el.addEventListener("click", function (e) {
data.ForceLogout = true;
postData(e);
});
})
document.querySelectorAll(".js-advertiseExitNode").forEach(function (el) {
el.addEventListener("click", function(e) {
data.AdvertiseExitNode = !advertiseExitNode;
postData(e);
});
})
}

69
webui/vite.config.ts Normal file
View File

@@ -0,0 +1,69 @@
/// <reference types="vitest" />
import { createLogger, defineConfig } from "vite"
import rewrite from "vite-plugin-rewrite-all"
import svgr from "vite-plugin-svgr"
import paths from "vite-tsconfig-paths"
// Use a custom logger that filters out Vite's logging of server URLs, since
// they are an attractive nuisance (we run a proxy in front of Vite, and the
// admin panel should be accessed through that).
// Unfortunately there's no option to disable this logging, so the best we can
// do it to ignore calls from a specific function.
const filteringLogger = createLogger(undefined, { allowClearScreen: false })
const originalInfoLog = filteringLogger.info
filteringLogger.info = (...args) => {
if (new Error("ignored").stack?.includes("printServerUrls")) {
return
}
originalInfoLog.apply(filteringLogger, args)
}
// https://vitejs.dev/config/
export default defineConfig({
base: "/",
plugins: [
paths(),
svgr(),
// By default, the Vite dev server doesn't handle dots in path names and
// treats them as static files, which breaks URLs like /admin/machines/100.101.102.103.
// This plugin changes Vite's routing logic to fix this.
// See: https://github.com/vitejs/vite/issues/2415
rewrite(),
],
build: {
outDir: "build",
sourcemap: true,
},
esbuild: {
logOverride: {
// Silence a warning about `this` being undefined in ESM when at the
// top-level. The way JSX is transpiled causes this to happen, but it
// isn't a problem.
// See: https://github.com/vitejs/vite/issues/8644
"this-is-undefined-in-esm": "silent",
},
},
server: {
// This needs to be 127.0.0.1 instead of localhost, because of how our
// Go proxy connects to it.
host: "127.0.0.1",
// If you change the port, be sure to update the proxy in adminhttp.go too.
port: 4000,
// Don't proxy the WebSocket connection used for live reloading by running
// it on a separate port.
hmr: {
protocol: "ws",
port: 4001,
},
},
test: {
exclude: ["**/node_modules/**", "**/dist/**"],
testTimeout: 20000,
environment: "jsdom",
deps: {
inline: ["date-fns", /\.wasm\?url$/],
},
},
clearScreen: false,
customLogger: filteringLogger,
})

68
webui/webui.go Normal file
View File

@@ -0,0 +1,68 @@
// Package webui provides the Tailscale client for web.
package webui
import (
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
type Server struct {
DevMode bool
}
func (s *Server) Start() {
}
func (s *Server) Handle(w http.ResponseWriter, r *http.Request) {
if s.DevMode {
au, _ := url.Parse("http://127.0.0.1:4000")
proxy := httputil.NewSingleHostReverseProxy(au)
proxy.ServeHTTP(w, r)
return
}
fmt.Fprintf(w, "Hello production")
}
func RunJSDevServer() (cleanup func()) {
root := gitRootDir()
webuiPath := filepath.Join(root, "webui")
yarn := filepath.Join(root, "tool", "yarn")
node := filepath.Join(root, "tool", "node")
vite := filepath.Join(webuiPath, "node_modules", ".bin", "vite")
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webuiPath, "install").CombinedOutput()
if err != nil {
log.Fatalf("error running admin panel's yarn install: %v, %s", err, out)
}
log.Printf("starting JavaScript dev server...")
cmd := exec.Command(node, vite)
cmd.Dir = webuiPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Fatalf("Starting JS dev server: %v", err)
}
log.Printf("JavaScript dev server running as pid %d", cmd.Process.Pid)
return func() {
cmd.Process.Signal(os.Interrupt)
err := cmd.Wait()
log.Printf("JavaScript dev server exited: %v", err)
}
}
func gitRootDir() string {
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
log.Fatalf("failed to find git top level (not in corp git?): %v", err)
}
return strings.TrimSpace(string(top))
}

1401
webui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,6 @@ package magicsock
import (
"bufio"
"context"
crand "crypto/rand"
"errors"
"fmt"
"io"
@@ -1242,10 +1241,6 @@ func (c *Conn) sendDiscoMessage(dst netip.AddrPort, dstKey key.NodePublic, dstDi
c.mu.Unlock()
return false, errConnClosed
}
var nonce [disco.NonceLen]byte
if _, err := crand.Read(nonce[:]); err != nil {
panic(err) // worth dying for
}
pkt := make([]byte, 0, 512) // TODO: size it correctly? pool? if it matters.
pkt = append(pkt, disco.Magic...)
pkt = c.discoPublic.AppendTo(pkt)

View File

@@ -2438,11 +2438,11 @@ func TestEndpointTracker(t *testing.T) {
got := et.update(tt.now, tt.eps)
// Sort both arrays for comparison
slices.SortFunc(got, func(a, b tailcfg.Endpoint) bool {
return a.Addr.String() < b.Addr.String()
slices.SortFunc(got, func(a, b tailcfg.Endpoint) int {
return strings.Compare(a.Addr.String(), b.Addr.String())
})
slices.SortFunc(tt.want, func(a, b tailcfg.Endpoint) bool {
return a.Addr.String() < b.Addr.String()
slices.SortFunc(tt.want, func(a, b tailcfg.Endpoint) int {
return strings.Compare(a.Addr.String(), b.Addr.String())
})
if !reflect.DeepEqual(got, tt.want) {

View File

@@ -396,7 +396,7 @@ func configureInterface(cfg *Config, tun *tun.NativeTun) (retErr error) {
return fmt.Errorf("syncAddresses: %w", err)
}
slices.SortFunc(routes, routeDataLess)
slices.SortFunc(routes, routeDataCompare)
deduplicatedRoutes := []*winipcfg.RouteData{}
for i := 0; i < len(routes); i++ {
@@ -652,8 +652,8 @@ func routeDataCompare(a, b *winipcfg.RouteData) int {
func deltaRouteData(a, b []*winipcfg.RouteData) (add, del []*winipcfg.RouteData) {
add = make([]*winipcfg.RouteData, 0, len(b))
del = make([]*winipcfg.RouteData, 0, len(a))
slices.SortFunc(a, routeDataLess)
slices.SortFunc(b, routeDataLess)
slices.SortFunc(a, routeDataCompare)
slices.SortFunc(b, routeDataCompare)
i := 0
j := 0

View File

@@ -20,6 +20,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/tailscale/wireguard-go/tun"
"github.com/vishvananda/netlink"
"go4.org/netipx"
"golang.org/x/exp/slices"
"tailscale.com/net/netmon"
"tailscale.com/net/tsaddr"
@@ -1022,8 +1023,8 @@ func TestCIDRDiff(t *testing.T) {
if err != nil {
t.Fatal(err)
}
slices.SortFunc(added, func(a, b netip.Prefix) bool { return a.Addr().Less(b.Addr()) })
slices.SortFunc(deleted, func(a, b netip.Prefix) bool { return a.Addr().Less(b.Addr()) })
slices.SortFunc(added, netipx.ComparePrefix)
slices.SortFunc(deleted, netipx.ComparePrefix)
if !reflect.DeepEqual(added, tc.wantAdd) {
t.Errorf("added = %v, want %v", added, tc.wantAdd)
}