Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ae5083960 | ||
|
|
3b239fe9e2 | ||
|
|
7e9868f50e | ||
|
|
d94244830b | ||
|
|
15a70243ed | ||
|
|
cf52316095 | ||
|
|
375fad6adb | ||
|
|
487ac2cf17 | ||
|
|
8cbf31ca49 | ||
|
|
4a0b942852 | ||
|
|
9a44000533 | ||
|
|
6fa024a8af | ||
|
|
d789fa3e85 | ||
|
|
862b569e8c | ||
|
|
7b631aa83e | ||
|
|
34841c4801 | ||
|
|
efdb8c56be | ||
|
|
37df6f9853 | ||
|
|
24338efd08 | ||
|
|
aab02cbf00 | ||
|
|
962cee914d | ||
|
|
07bbd6901b | ||
|
|
c1c1f26c90 | ||
|
|
101a52e75c | ||
|
|
23ef28b4ae | ||
|
|
18db7a0f94 | ||
|
|
0b277058d3 | ||
|
|
038aa47b83 | ||
|
|
06258280de | ||
|
|
b9555a463b | ||
|
|
b2547cc664 | ||
|
|
358f47bc79 | ||
|
|
58095f829c | ||
|
|
d42da2fbd7 | ||
|
|
c695e579fa |
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package driveprobe reports whether a peer currently exposes Taildrive
|
||||
// shares, by asking its peerAPI rather than by inspecting ACL capabilities.
|
||||
//
|
||||
// PeerCapabilityTaildriveSharer only says a peer is allowed to share with us.
|
||||
// The share list itself is only visible over WebDAV, so this package issues a
|
||||
// Depth-1 PROPFIND against the peer's Taildrive root and looks for children.
|
||||
// The peer applies our permissions before listing, so a child is a share we
|
||||
// can actually reach.
|
||||
//
|
||||
// This lives outside the wasm package so it can be tested without syscall/js.
|
||||
package driveprobe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// drivePath is the peerAPI prefix taildrive is served under.
|
||||
drivePath = "/v0/drive/"
|
||||
|
||||
// maxProbes bounds how many probes are in flight at once. Go under wasm
|
||||
// runs on a single thread, so a high limit buys little and costs memory.
|
||||
maxProbes = 8
|
||||
|
||||
// maxResponseBytes bounds the listing we are willing to read. A peer with
|
||||
// a plausible number of shares is far below this.
|
||||
maxResponseBytes = 1 << 20
|
||||
|
||||
// probeTimeout bounds a single probe in HasSharesMulti, so one peer that
|
||||
// accepts the connection and then stalls cannot hold up the listing.
|
||||
probeTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// propfindBody asks only for resourcetype: we care whether children exist,
|
||||
// not what they are.
|
||||
const propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
|
||||
|
||||
// HasShares reports whether the peer at peerAPIURL exposes at least one
|
||||
// Taildrive share to us.
|
||||
//
|
||||
// A false result means the peer answered and listed nothing. An error means we
|
||||
// could not find out — callers must not read it as "no shares".
|
||||
func HasShares(ctx context.Context, c *http.Client, peerAPIURL string) (bool, error) {
|
||||
u := strings.TrimSuffix(peerAPIURL, "/") + drivePath
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", u, strings.NewReader(propfindBody))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Depth", "1")
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes))
|
||||
resp.Body.Close()
|
||||
}()
|
||||
|
||||
// Anything other than 207 is the peer declining to list: taildrive off
|
||||
// (404), no cap for us (403), or a handler that does not speak WebDAV.
|
||||
if resp.StatusCode != http.StatusMultiStatus {
|
||||
return false, fmt.Errorf("PROPFIND %s: %s", u, resp.Status)
|
||||
}
|
||||
|
||||
return hasChild(io.LimitReader(resp.Body, maxResponseBytes), drivePath)
|
||||
}
|
||||
|
||||
// hasChild reports whether a multistatus body lists anything besides the
|
||||
// collection we asked about. It decodes as a stream and stops at the first
|
||||
// child, so a peer with many shares costs no more than a peer with one.
|
||||
//
|
||||
// RFC 4918 §9.1 says a Depth-1 PROPFIND answers with the collection itself
|
||||
// followed by its members, so anything after the first href is a share. The
|
||||
// first href counts only if it is itself below root, which catches a peer that
|
||||
// answers about a subtree rather than the collection we asked for.
|
||||
//
|
||||
// A peer that omits the collection entirely is not understood: ipnlocal strips
|
||||
// the taildrive prefix before handing the request to the share server, so a
|
||||
// real peer's members are named "/docs" rather than "/v0/drive/docs" and a lone
|
||||
// member is indistinguishable from the collection. Such a peer is reported as
|
||||
// having no shares, which is the safe direction for a positive filter.
|
||||
func hasChild(body io.Reader, root string) (bool, error) {
|
||||
dec := xml.NewDecoder(body)
|
||||
var href string
|
||||
inHref, first := false, true
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse multistatus: %w", err)
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Space == "DAV:" && t.Name.Local == "href" {
|
||||
inHref, href = true, ""
|
||||
}
|
||||
case xml.CharData:
|
||||
// Character data can arrive in several tokens for one element.
|
||||
if inHref {
|
||||
href += string(t)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if !inHref {
|
||||
continue
|
||||
}
|
||||
inHref = false
|
||||
if !first {
|
||||
return true, nil
|
||||
}
|
||||
first = false
|
||||
if isBelow(href, root) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isBelow reports whether href points below root. Peers may answer with an
|
||||
// absolute URL or a path, percent-encoded and with or without a trailing
|
||||
// slash, so compare cleaned paths rather than strings.
|
||||
func isBelow(href, root string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(href))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
p := path.Clean("/" + strings.Trim(u.Path, "/"))
|
||||
r := path.Clean("/" + strings.Trim(root, "/"))
|
||||
if p == r || p == "/" {
|
||||
return false
|
||||
}
|
||||
return r == "/" || strings.HasPrefix(p, r+"/")
|
||||
}
|
||||
|
||||
// HasSharesMulti probes every URL and returns one result per input, in input
|
||||
// order. A probe that fails is reported as false and logged: the caller is
|
||||
// filtering to peers we positively confirmed, and one unreachable peer must
|
||||
// not sink the rest.
|
||||
func HasSharesMulti(ctx context.Context, c *http.Client, urls []string, logf logger.Logf) []bool {
|
||||
out := make([]bool, len(urls))
|
||||
var mu sync.Mutex
|
||||
|
||||
// Deliberately not errgroup.WithContext: a failing probe must not cancel
|
||||
// its siblings.
|
||||
var g errgroup.Group
|
||||
g.SetLimit(maxProbes)
|
||||
for i, u := range urls {
|
||||
g.Go(func() error {
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
ok, err := HasShares(ctx, c, u)
|
||||
if err != nil {
|
||||
logf("driveprobe: %s: %v", u, err)
|
||||
return nil
|
||||
}
|
||||
mu.Lock()
|
||||
out[i] = ok
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
g.Wait()
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package driveprobe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func multistatus(hrefs ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:">`)
|
||||
for _, h := range hrefs {
|
||||
fmt.Fprintf(&b, `<D:response><D:href>%s</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`, h)
|
||||
}
|
||||
b.WriteString(`</D:multistatus>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestHasChild(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"root only, prefix stripped", multistatus("/"), false},
|
||||
{"root only, prefix kept", multistatus("/v0/drive/"), false},
|
||||
{"root only, no trailing slash", multistatus("/v0/drive"), false},
|
||||
{"one share, prefix stripped", multistatus("/", "/docs"), true},
|
||||
{"one share, prefix kept", multistatus("/v0/drive/", "/v0/drive/docs"), true},
|
||||
{"absolute urls", multistatus("http://100.1.2.3:1234/v0/drive/", "http://100.1.2.3:1234/v0/drive/docs"), true},
|
||||
{"percent-encoded share name", multistatus("/v0/drive/", "/v0/drive/my%20share"), true},
|
||||
{"unicode share name", multistatus("/v0/drive/", "/v0/drive/%E6%97%A5%E6%9C%AC"), true},
|
||||
{"empty multistatus", multistatus(), false},
|
||||
// The collection comes first per RFC 4918 §9.1, so anything after it
|
||||
// is a share whatever the peer names it.
|
||||
{"unrelated collection href, no members", multistatus("/somewhere/else/"), false},
|
||||
{"unrelated collection href with a member", multistatus("/somewhere/else/", "/somewhere/else/docs"), true},
|
||||
// A peer that omits the collection from a Depth-1 listing violates
|
||||
// RFC 4918 §9.1, and once the taildrive prefix is stripped there is
|
||||
// nothing left to tell its lone member apart from the collection. It
|
||||
// loses the benefit of the doubt: hasShares excludes what it cannot
|
||||
// confirm.
|
||||
{"single member, collection omitted", multistatus("/docs"), false},
|
||||
{"href split by an entity reference", multistatus("/v0/drive/", "/v0/drive/a&b"), true},
|
||||
{"empty href", multistatus("/v0/drive/", ""), true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := hasChild(strings.NewReader(tt.body), drivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("hasChild: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("hasChild = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasChildMalformed(t *testing.T) {
|
||||
body := strings.TrimSuffix(multistatus("/v0/drive/", "/v0/drive/docs"), "</D:multistatus>")
|
||||
// Truncation after a child href still answers the question.
|
||||
got, err := hasChild(strings.NewReader(body), drivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("hasChild: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Error("hasChild = false on a truncated body that already listed a share")
|
||||
}
|
||||
|
||||
if _, err := hasChild(strings.NewReader("<D:multistatus"), drivePath); err == nil {
|
||||
t.Error("hasChild on malformed XML: want error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// serveDrive returns a server answering PROPFIND at the taildrive root with the
|
||||
// given body and status, and records the requests it saw.
|
||||
func serveDrive(t *testing.T, status int, body string) (*httptest.Server, *[]*http.Request) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var reqs []*http.Request
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
reqs = append(reqs, r)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(status)
|
||||
io := []byte(body)
|
||||
w.Write(io)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &reqs
|
||||
}
|
||||
|
||||
func TestHasShares(t *testing.T) {
|
||||
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
|
||||
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("HasShares: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Error("HasShares = false, want true")
|
||||
}
|
||||
|
||||
if len(*reqs) != 1 {
|
||||
t.Fatalf("got %d requests, want 1", len(*reqs))
|
||||
}
|
||||
r := (*reqs)[0]
|
||||
if r.Method != "PROPFIND" {
|
||||
t.Errorf("method = %q, want PROPFIND", r.Method)
|
||||
}
|
||||
if r.URL.Path != drivePath {
|
||||
t.Errorf("path = %q, want %q", r.URL.Path, drivePath)
|
||||
}
|
||||
if d := r.Header.Get("Depth"); d != "1" {
|
||||
t.Errorf("Depth = %q, want 1", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSharesTrailingSlashInPeerURL(t *testing.T) {
|
||||
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/"))
|
||||
if _, err := HasShares(context.Background(), srv.Client(), srv.URL+"/"); err != nil {
|
||||
t.Fatalf("HasShares: %v", err)
|
||||
}
|
||||
if p := (*reqs)[0].URL.Path; p != drivePath {
|
||||
t.Errorf("path = %q, want %q", p, drivePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSharesNonMultiStatus(t *testing.T) {
|
||||
// Taildrive disabled, or we hold no cap on that peer.
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusOK} {
|
||||
srv, _ := serveDrive(t, status, "")
|
||||
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
|
||||
if err == nil {
|
||||
t.Errorf("status %d: want error, got nil", status)
|
||||
}
|
||||
if got {
|
||||
t.Errorf("status %d: HasShares = true", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSharesCancelled(t *testing.T) {
|
||||
// The handler must also unblock on release: a client-side cancel does not
|
||||
// reliably reach the server's request context, and Close waits for it.
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-release:
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer close(release)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := HasShares(ctx, srv.Client(), srv.URL); err == nil {
|
||||
t.Error("want error on cancelled probe, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSharesMultiOrderAndFailures(t *testing.T) {
|
||||
withShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
|
||||
noShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/"))
|
||||
refused, _ := serveDrive(t, http.StatusNotFound, "")
|
||||
|
||||
urls := []string{noShares.URL, withShares.URL, refused.URL, "http://127.0.0.1:1/dead", withShares.URL}
|
||||
want := []bool{false, true, false, false, true}
|
||||
|
||||
var logs atomic.Int32
|
||||
got := HasSharesMulti(context.Background(), withShares.Client(), urls, func(string, ...any) {
|
||||
logs.Add(1)
|
||||
})
|
||||
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("result[%d] = %v, want %v (%s)", i, got[i], want[i], urls[i])
|
||||
}
|
||||
}
|
||||
// The 404 peer and the dead address are both reported, not swallowed.
|
||||
if n := logs.Load(); n != 2 {
|
||||
t.Errorf("logged %d probe failures, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSharesMultiRunsInParallel(t *testing.T) {
|
||||
const delay = 100 * time.Millisecond
|
||||
var inFlight, peak atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := inFlight.Add(1)
|
||||
for {
|
||||
old := peak.Load()
|
||||
if n <= old || peak.CompareAndSwap(old, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(delay)
|
||||
inFlight.Add(-1)
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
w.Write([]byte(multistatus("/v0/drive/", "/v0/drive/docs")))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
urls := make([]string, maxProbes)
|
||||
for i := range urls {
|
||||
urls[i] = srv.URL
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
got := HasSharesMulti(context.Background(), srv.Client(), urls, func(string, ...any) {})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
for i, ok := range got {
|
||||
if !ok {
|
||||
t.Errorf("result[%d] = false, want true", i)
|
||||
}
|
||||
}
|
||||
if elapsed >= delay*time.Duration(len(urls)) {
|
||||
t.Errorf("probes serialized: %v for %d probes of %v each", elapsed, len(urls), delay)
|
||||
}
|
||||
if peak.Load() < 2 {
|
||||
t.Errorf("peak concurrency = %d, want >= 2", peak.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSharesMultiLimitsConcurrency(t *testing.T) {
|
||||
var inFlight, peak atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := inFlight.Add(1)
|
||||
for {
|
||||
old := peak.Load()
|
||||
if n <= old || peak.CompareAndSwap(old, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
inFlight.Add(-1)
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
w.Write([]byte(multistatus("/v0/drive/")))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
urls := make([]string, maxProbes*3)
|
||||
for i := range urls {
|
||||
urls[i] = srv.URL
|
||||
}
|
||||
client := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 0}}
|
||||
HasSharesMulti(context.Background(), client, urls, func(string, ...any) {})
|
||||
|
||||
if peak.Load() > maxProbes {
|
||||
t.Errorf("peak concurrency = %d, want <= %d", peak.Load(), maxProbes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_drive
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"syscall/js"
|
||||
|
||||
"tailscale.com/cmd/tsconnect/driveprobe"
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsd"
|
||||
)
|
||||
|
||||
// Compile-time check that jsFileSystemForRemote implements drive.FileSystemForRemote.
|
||||
var _ drive.FileSystemForRemote = (*jsFileSystemForRemote)(nil)
|
||||
|
||||
// jsFileSystemForRemote implements drive.FileSystemForRemote by bridging
|
||||
// incoming WebDAV requests to a JS handler function. Auth and permission
|
||||
// parsing are handled upstream by handleServeDrive before this is called.
|
||||
type jsFileSystemForRemote struct {
|
||||
mu sync.RWMutex
|
||||
fn js.Value
|
||||
}
|
||||
|
||||
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
|
||||
fs.mu.Lock()
|
||||
fs.fn = fn
|
||||
fs.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetFileServerAddr is a no-op: the JS handler owns its own storage.
|
||||
func (fs *jsFileSystemForRemote) SetFileServerAddr(_ string) {}
|
||||
|
||||
// SetShares is a no-op: the JS handler controls which shares it exposes.
|
||||
func (fs *jsFileSystemForRemote) SetShares(_ []*drive.Share) {}
|
||||
|
||||
// Close is a no-op.
|
||||
func (fs *jsFileSystemForRemote) Close() error { return nil }
|
||||
|
||||
// ServeHTTPWithPerms handles a WebDAV request by bridging it to the JS handler.
|
||||
// It streams the request body to JS via readBodyChunk() and streams the
|
||||
// response body back via write()/end() callbacks, so no full-body buffering
|
||||
// occurs regardless of file size.
|
||||
//
|
||||
// The call blocks until JS calls end() (or a write error occurs).
|
||||
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
||||
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
fs.mu.RLock()
|
||||
fn := fs.fn
|
||||
fs.mu.RUnlock()
|
||||
|
||||
if fn.IsUndefined() || fn.IsNull() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// readBodyChunk is exposed to JS as req.readBodyChunk().
|
||||
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
|
||||
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
return makePromise(func() (any, error) {
|
||||
buf := make([]byte, 65536)
|
||||
n, err := r.Body.Read(buf)
|
||||
if n > 0 {
|
||||
arr := js.Global().Get("Uint8Array").New(n)
|
||||
js.CopyBytesToJS(arr, buf[:n])
|
||||
return arr, nil
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return js.Null(), nil
|
||||
}
|
||||
return nil, err
|
||||
})
|
||||
})
|
||||
|
||||
// doneCh receives nil when JS calls end(), or a write error if Write fails.
|
||||
doneCh := make(chan error, 1)
|
||||
|
||||
// writeHead sets response headers and status code. Must be called before write().
|
||||
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
return nil
|
||||
}
|
||||
status := args[0].Int()
|
||||
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
|
||||
for k, vs := range jsHeadersToGo(args[1]) {
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
return nil
|
||||
})
|
||||
|
||||
// write streams a single response body chunk to the client.
|
||||
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
return nil
|
||||
}
|
||||
data := args[0]
|
||||
buf := make([]byte, data.Get("length").Int())
|
||||
js.CopyBytesToGo(buf, data)
|
||||
if _, werr := w.Write(buf); werr != nil {
|
||||
select {
|
||||
case doneCh <- werr:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// end signals that the response is complete.
|
||||
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
select {
|
||||
case doneCh <- nil:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
defer func() {
|
||||
readBodyChunk.Release()
|
||||
writeHead.Release()
|
||||
write.Release()
|
||||
end.Release()
|
||||
}()
|
||||
|
||||
jsReq := map[string]any{
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"rawQuery": r.URL.RawQuery,
|
||||
"headers": goHeadersToJS(r.Header),
|
||||
"readBodyChunk": readBodyChunk,
|
||||
}
|
||||
jsRes := map[string]any{
|
||||
"writeHead": writeHead,
|
||||
"write": write,
|
||||
"end": end,
|
||||
}
|
||||
|
||||
fn.Invoke(jsReq, jsRes, drivePermsToJS(perms))
|
||||
|
||||
// Block this goroutine until JS calls end() or a write error occurs.
|
||||
// The Go WASM scheduler yields back to JS while we wait.
|
||||
<-doneCh
|
||||
}
|
||||
|
||||
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
|
||||
// Each share name maps to a numeric permission: 0=none, 1=read-only, 2=read-write.
|
||||
// The wildcard share name "*" is included if present.
|
||||
func drivePermsToJS(p drive.Permissions) map[string]any {
|
||||
result := make(map[string]any, len(p))
|
||||
for name, perm := range p {
|
||||
result[name] = int(perm)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// goHeadersToJS converts an http.Header to a map[string]any suitable for JS.
|
||||
// Single-value headers become a string; multi-value headers become a []any.
|
||||
func goHeadersToJS(h http.Header) map[string]any {
|
||||
result := make(map[string]any, len(h))
|
||||
for k, vs := range h {
|
||||
if len(vs) == 1 {
|
||||
result[k] = vs[0]
|
||||
} else {
|
||||
arr := make([]any, len(vs))
|
||||
for i, v := range vs {
|
||||
arr[i] = v
|
||||
}
|
||||
result[k] = arr
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// jsHeadersToGo parses a JS headers object into an http.Header map.
|
||||
// Values may be a string or an array of strings.
|
||||
func jsHeadersToGo(jsHeaders js.Value) http.Header {
|
||||
h := make(http.Header)
|
||||
keys := js.Global().Get("Object").Call("keys", jsHeaders)
|
||||
for i := 0; i < keys.Length(); i++ {
|
||||
key := keys.Index(i).String()
|
||||
val := jsHeaders.Get(key)
|
||||
switch val.Type() {
|
||||
case js.TypeString:
|
||||
h.Set(key, val.String())
|
||||
case js.TypeObject:
|
||||
if val.InstanceOf(js.Global().Get("Array")) {
|
||||
for j := 0; j < val.Length(); j++ {
|
||||
h.Add(key, val.Index(j).String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// initDriveForRemote creates the JS-backed FileSystemForRemote and registers
|
||||
// it with sys. Must be called before NewLocalBackend (SubSystem is set-once).
|
||||
func initDriveForRemote(sys *tsd.System) *jsFileSystemForRemote {
|
||||
driveFS := &jsFileSystemForRemote{}
|
||||
sys.Set(driveFS)
|
||||
return driveFS
|
||||
}
|
||||
|
||||
// wireDriveJS adds drive-related methods to the IPN JS methods map.
|
||||
// driveFS must be the value returned by initDriveForRemote.
|
||||
func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
|
||||
m["setDriveHandler"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
return nil
|
||||
}
|
||||
driveFS.setHandler(args[0])
|
||||
return nil
|
||||
})
|
||||
|
||||
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
var hasShares bool
|
||||
if len(args) > 0 && !args[0].IsUndefined() && !args[0].IsNull() {
|
||||
if v := args[0].Get("hasShares"); v.Type() == js.TypeBoolean {
|
||||
hasShares = v.Bool()
|
||||
}
|
||||
}
|
||||
return i.listDrivePeers(hasShares)
|
||||
})
|
||||
}
|
||||
|
||||
// filterPeersWithShares keeps only the peers whose Taildrive endpoint lists at
|
||||
// least one share for us, preserving the order of the input.
|
||||
func (i *jsIPN) filterPeersWithShares(peers []jsDrivePeer) []jsDrivePeer {
|
||||
urls := make([]string, len(peers))
|
||||
for n, p := range peers {
|
||||
urls[n] = p.PeerAPIURL
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
|
||||
results := driveprobe.HasSharesMulti(context.Background(), client, urls, log.Printf)
|
||||
|
||||
kept := make([]jsDrivePeer, 0, len(peers))
|
||||
for n, p := range peers {
|
||||
if results[n] {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
type jsDrivePeer struct {
|
||||
Name string `json:"name"`
|
||||
PeerAPIURL string `json:"peerAPIURL"`
|
||||
StableNodeID string `json:"stableNodeID"`
|
||||
Online *bool `json:"online,omitempty"`
|
||||
}
|
||||
|
||||
// listDrivePeers returns a JSON array of peers that are online, have a
|
||||
// reachable peerAPI and carry PeerCapabilityTaildriveSharer. Returns an empty
|
||||
// array if the local node does not have drive:access in its ACL
|
||||
// (DriveAccessEnabled). This mirrors the filtering in
|
||||
// LocalBackend.driveRemotesFromPeers.
|
||||
//
|
||||
// The cap means a peer is allowed to share with us, not that it currently
|
||||
// exposes any share, so the result is a superset of the peers with shares.
|
||||
//
|
||||
// hasShares narrows it to peers we confirmed are exposing at least one share,
|
||||
// at the cost of one peerAPI round-trip per candidate (run in parallel). It is
|
||||
// a positive filter: a peer we could not reach is left out, which is not the
|
||||
// same as knowing it has no shares.
|
||||
func (i *jsIPN) listDrivePeers(hasShares bool) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
if !i.lb.DriveAccessEnabled() {
|
||||
return "[]", nil
|
||||
}
|
||||
|
||||
nm := i.lb.NetMap()
|
||||
if nm == nil {
|
||||
return nil, errors.New("listDrivePeers: no network map available")
|
||||
}
|
||||
|
||||
var selfHave4, selfHave6 bool
|
||||
for _, a := range nm.GetAddresses().All() {
|
||||
if !a.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if a.Addr().Is4() {
|
||||
selfHave4 = true
|
||||
} else if a.Addr().Is6() {
|
||||
selfHave6 = true
|
||||
}
|
||||
}
|
||||
|
||||
peers := make([]jsDrivePeer, 0)
|
||||
for _, p := range nm.Peers {
|
||||
if !p.Online().Get() {
|
||||
continue
|
||||
}
|
||||
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
|
||||
if peerURL == "" {
|
||||
continue
|
||||
}
|
||||
// Check PeerCapabilityTaildriveSharer via the live PeerCaps map
|
||||
// (derived from ACL rules), mirroring driveRemotesFromPeers.
|
||||
hasCap := false
|
||||
for _, a := range p.Addresses().All() {
|
||||
if a.IsSingleIP() && i.lb.PeerCaps(a.Addr()).HasCapability(tailcfg.PeerCapabilityTaildriveSharer) {
|
||||
hasCap = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasCap {
|
||||
continue
|
||||
}
|
||||
online := p.Online().Clone()
|
||||
peers = append(peers, jsDrivePeer{
|
||||
Name: p.DisplayName(false),
|
||||
PeerAPIURL: peerURL,
|
||||
StableNodeID: string(p.StableID()),
|
||||
Online: online,
|
||||
})
|
||||
}
|
||||
|
||||
if hasShares && len(peers) > 0 {
|
||||
peers = i.filterPeersWithShares(peers)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(peers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build ts_omit_drive
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"tailscale.com/tsd"
|
||||
)
|
||||
|
||||
type jsFileSystemForRemote struct{}
|
||||
|
||||
// initDriveForRemote is a no-op when the drive feature is omitted.
|
||||
func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil }
|
||||
|
||||
// wireDriveJS is a no-op when the drive feature is omitted.
|
||||
func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {}
|
||||
|
||||
// listDrivePeers returns an empty list when the drive feature is omitted.
|
||||
func (i *jsIPN) listDrivePeers(_ bool) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
return "[]", nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// buildPeerAPIURL returns the HTTP base URL for a peer's peerAPI server,
|
||||
// selecting IPv4 when available and falling back to IPv6. Returns an empty
|
||||
// string if the peer advertises no reachable peerAPI port.
|
||||
func buildPeerAPIURL(p tailcfg.NodeView, selfHave4, selfHave6 bool) string {
|
||||
var pp4, pp6 uint16
|
||||
for _, s := range p.Hostinfo().Services().All() {
|
||||
switch s.Proto {
|
||||
case tailcfg.PeerAPI4:
|
||||
pp4 = s.Port
|
||||
case tailcfg.PeerAPI6:
|
||||
pp6 = s.Port
|
||||
}
|
||||
}
|
||||
if selfHave4 && pp4 != 0 {
|
||||
for _, a := range p.Addresses().All() {
|
||||
if a.IsSingleIP() && a.Addr().Is4() {
|
||||
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
|
||||
}
|
||||
}
|
||||
}
|
||||
if selfHave6 && pp6 != 0 {
|
||||
for _, a := range p.Addresses().All() {
|
||||
if a.IsSingleIP() && a.Addr().Is6() {
|
||||
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+160
-291
@@ -6,11 +6,10 @@
|
||||
//
|
||||
// When run in the browser, a newIPN(config) function is added to the global JS
|
||||
// namespace. When called it returns an ipn object with the methods
|
||||
// run(callbacks), login(), logout(), and ssh(...).
|
||||
// run(callbacks), login(), and logout().
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
@@ -31,7 +30,6 @@ import (
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
@@ -40,6 +38,7 @@ import (
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
|
||||
"gvisor.dev/gvisor/pkg/waiter"
|
||||
"tailscale.com/control/controlclient"
|
||||
_ "tailscale.com/feature/condregister"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnauth"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
@@ -173,6 +172,10 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
sys.Tun.Get().Start()
|
||||
|
||||
logid := lpc.PublicID
|
||||
|
||||
// initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
|
||||
driveFS := initDriveForRemote(sys)
|
||||
|
||||
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
|
||||
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
|
||||
if err != nil {
|
||||
@@ -198,7 +201,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
}
|
||||
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
||||
|
||||
return map[string]any{
|
||||
m := map[string]any{
|
||||
"run": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
log.Fatal(`Usage: run({
|
||||
@@ -228,25 +231,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
jsIPN.logout()
|
||||
return nil
|
||||
}),
|
||||
"ssh": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 3 {
|
||||
log.Printf("Usage: ssh(hostname, userName, termConfig)")
|
||||
return nil
|
||||
}
|
||||
return jsIPN.ssh(
|
||||
args[0].String(),
|
||||
args[1].String(),
|
||||
args[2])
|
||||
}),
|
||||
"fetch": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
log.Printf("Usage: fetch(url)")
|
||||
return nil
|
||||
}
|
||||
|
||||
url := args[0].String()
|
||||
return jsIPN.fetch(url)
|
||||
}),
|
||||
"dial": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 2 {
|
||||
log.Printf("Usage: dial(network, addr)")
|
||||
@@ -286,13 +270,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
}
|
||||
return jsIPN.setExitNode(args[0].String())
|
||||
}),
|
||||
"setExitNodeEnabled": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
log.Printf("Usage: setExitNodeEnabled(enabled)")
|
||||
return nil
|
||||
}
|
||||
return jsIPN.setExitNodeEnabled(args[0].Bool())
|
||||
}),
|
||||
"listFileTargets": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return jsIPN.listFileTargets()
|
||||
}),
|
||||
@@ -380,6 +357,13 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
"shutdown": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return jsIPN.shutdown()
|
||||
}),
|
||||
"setServices": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
log.Printf("Usage: setServices(services)")
|
||||
return nil
|
||||
}
|
||||
return jsIPN.setServices(args[0])
|
||||
}),
|
||||
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) < 2 {
|
||||
log.Printf("Usage: localAPI(method, path[, body])")
|
||||
@@ -392,6 +376,8 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
return jsIPN.localAPI(args[0].String(), args[1].String(), body)
|
||||
}),
|
||||
}
|
||||
wireDriveJS(jsIPN, driveFS, m)
|
||||
return m
|
||||
}
|
||||
|
||||
type jsIPN struct {
|
||||
@@ -497,6 +483,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||
NodeKey: nm.NodeKey.String(),
|
||||
MachineKey: nm.MachineKey.String(),
|
||||
PeerAPIURL: selfPeerAPIURL,
|
||||
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
|
||||
},
|
||||
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
|
||||
},
|
||||
@@ -512,32 +499,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||
}
|
||||
|
||||
// Peer peerAPI URL from the peer's advertised Services.
|
||||
peerURL := ""
|
||||
var pp4, pp6 uint16
|
||||
for _, s := range p.Hostinfo().Services().All() {
|
||||
switch s.Proto {
|
||||
case tailcfg.PeerAPI4:
|
||||
pp4 = s.Port
|
||||
case tailcfg.PeerAPI6:
|
||||
pp6 = s.Port
|
||||
}
|
||||
}
|
||||
if selfHave4 && pp4 != 0 {
|
||||
for _, a := range p.Addresses().All() {
|
||||
if a.IsSingleIP() && a.Addr().Is4() {
|
||||
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if peerURL == "" && selfHave6 && pp6 != 0 {
|
||||
for _, a := range p.Addresses().All() {
|
||||
if a.IsSingleIP() && a.Addr().Is6() {
|
||||
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
|
||||
|
||||
return jsNetMapPeerNode{
|
||||
jsNetMapNode: jsNetMapNode{
|
||||
@@ -546,6 +508,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||
MachineKey: p.Machine().String(),
|
||||
NodeKey: p.Key().String(),
|
||||
PeerAPIURL: peerURL,
|
||||
Services: userServicesFromView(p.Hostinfo().Services()),
|
||||
},
|
||||
Online: p.Online().Clone(),
|
||||
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
|
||||
@@ -657,201 +620,18 @@ func (i *jsIPN) logout() {
|
||||
func (i *jsIPN) shutdown() js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
i.shutdownOnce.Do(func() {
|
||||
i.lb.Shutdown()
|
||||
i.ln.Close()
|
||||
if i.lb != nil {
|
||||
i.lb.Shutdown()
|
||||
}
|
||||
if i.ln != nil {
|
||||
i.ln.Close()
|
||||
}
|
||||
close(i.shutdownCh)
|
||||
})
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
|
||||
jsSSHSession := &jsSSHSession{
|
||||
jsIPN: i,
|
||||
host: host,
|
||||
username: username,
|
||||
termConfig: termConfig,
|
||||
}
|
||||
|
||||
go jsSSHSession.Run()
|
||||
|
||||
return map[string]any{
|
||||
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return jsSSHSession.Close() != nil
|
||||
}),
|
||||
"resize": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
rows := args[0].Int()
|
||||
cols := args[1].Int()
|
||||
return jsSSHSession.Resize(rows, cols) != nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type jsSSHSession struct {
|
||||
jsIPN *jsIPN
|
||||
host string
|
||||
username string
|
||||
termConfig js.Value
|
||||
session *ssh.Session
|
||||
|
||||
pendingResizeRows int
|
||||
pendingResizeCols int
|
||||
}
|
||||
|
||||
func (s *jsSSHSession) Run() {
|
||||
writeFn := s.termConfig.Get("writeFn")
|
||||
writeErrorFn := s.termConfig.Get("writeErrorFn")
|
||||
setReadFn := s.termConfig.Get("setReadFn")
|
||||
rows := s.termConfig.Get("rows").Int()
|
||||
cols := s.termConfig.Get("cols").Int()
|
||||
timeoutSeconds := 5.0
|
||||
if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber {
|
||||
timeoutSeconds = jsTimeoutSeconds.Float()
|
||||
}
|
||||
onConnectionProgress := s.termConfig.Get("onConnectionProgress")
|
||||
onConnected := s.termConfig.Get("onConnected")
|
||||
onDone := s.termConfig.Get("onDone")
|
||||
defer onDone.Invoke()
|
||||
|
||||
writeError := func(label string, err error) {
|
||||
writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err))
|
||||
}
|
||||
reportProgress := func(message string) {
|
||||
onConnectionProgress.Invoke(message)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second)))
|
||||
defer cancel()
|
||||
reportProgress(fmt.Sprintf("Connecting to %s…", strings.Split(s.host, ".")[0]))
|
||||
c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22"))
|
||||
if err != nil {
|
||||
writeError("Dial", err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
// Host keys are not used with Tailscale SSH, but we can use this
|
||||
// callback to know that the connection has been established.
|
||||
reportProgress("SSH connection established…")
|
||||
return nil
|
||||
},
|
||||
User: s.username,
|
||||
}
|
||||
|
||||
reportProgress("Starting SSH client…")
|
||||
sshConn, _, _, err := ssh.NewClientConn(c, s.host, config)
|
||||
if err != nil {
|
||||
writeError("SSH Connection", err)
|
||||
return
|
||||
}
|
||||
defer sshConn.Close()
|
||||
|
||||
sshClient := ssh.NewClient(sshConn, nil, nil)
|
||||
defer sshClient.Close()
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
writeError("SSH Session", err)
|
||||
return
|
||||
}
|
||||
s.session = session
|
||||
defer session.Close()
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
writeError("SSH Stdin", err)
|
||||
return
|
||||
}
|
||||
|
||||
session.Stdout = termWriter{writeFn}
|
||||
session.Stderr = termWriter{writeFn}
|
||||
|
||||
setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
input := args[0].String()
|
||||
_, err := stdin.Write([]byte(input))
|
||||
if err != nil {
|
||||
writeError("Write Input", err)
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// We might have gotten a resize notification since we started opening the
|
||||
// session, pick up the latest size.
|
||||
if s.pendingResizeRows != 0 {
|
||||
rows = s.pendingResizeRows
|
||||
}
|
||||
if s.pendingResizeCols != 0 {
|
||||
cols = s.pendingResizeCols
|
||||
}
|
||||
err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
|
||||
if err != nil {
|
||||
writeError("Pseudo Terminal", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = session.Shell()
|
||||
if err != nil {
|
||||
writeError("Shell", err)
|
||||
return
|
||||
}
|
||||
|
||||
onConnected.Invoke()
|
||||
err = session.Wait()
|
||||
if err != nil {
|
||||
writeError("Wait", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *jsSSHSession) Close() error {
|
||||
if s.session == nil {
|
||||
// We never had a chance to open the session, ignore the close request.
|
||||
return nil
|
||||
}
|
||||
return s.session.Close()
|
||||
}
|
||||
|
||||
func (s *jsSSHSession) Resize(rows, cols int) error {
|
||||
if s.session == nil {
|
||||
s.pendingResizeRows = rows
|
||||
s.pendingResizeCols = cols
|
||||
return nil
|
||||
}
|
||||
return s.session.WindowChange(rows, cols)
|
||||
}
|
||||
|
||||
func (i *jsIPN) fetch(url string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
c := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: i.dialer.UserDial,
|
||||
},
|
||||
}
|
||||
res, err := c.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": res.StatusCode,
|
||||
"statusText": res.Status,
|
||||
"text": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return makePromise(func() (any, error) {
|
||||
defer res.Body.Close()
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := buf.ReadFrom(res.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.String(), nil
|
||||
})
|
||||
}),
|
||||
// TODO: populate a more complete JS Response object
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) setExitNode(stableNodeID string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
mp := &ipn.MaskedPrefs{
|
||||
@@ -863,13 +643,6 @@ func (i *jsIPN) setExitNode(stableNodeID string) js.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) setExitNodeEnabled(enabled bool) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
_, err := i.lb.SetUseExitNodeEnabled(ipnauth.Self, enabled)
|
||||
return nil, err
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) dial(network, addr string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@@ -893,6 +666,11 @@ func (i *jsIPN) listen(network, addr string) js.Value {
|
||||
if n == "tcp" {
|
||||
n = "tcp4"
|
||||
}
|
||||
// netstack.ListenTCP requires a full host:port; normalise the
|
||||
// standard net.Listen form ":port" that omits the host.
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
addr = "0.0.0.0" + addr
|
||||
}
|
||||
ln, err := i.ns.ListenTCP(n, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -910,6 +688,41 @@ func (i *jsIPN) listen(network, addr string) js.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// tlsClientConfigFromJS builds a client tls.Config from optional JS options
|
||||
// (serverName, insecureSkipVerify, caCerts). defaultServerName may be empty
|
||||
// (STARTTLS upgrade case), in which case serverName must be provided unless
|
||||
// insecureSkipVerify is set.
|
||||
//
|
||||
// On wasm there's no system root pool, so default to the baked-in
|
||||
// LetsEncrypt roots (which is what `tailscale cert` uses for tailnet
|
||||
// HTTPS endpoints). Callers can override with caCerts (PEM) or bypass
|
||||
// entirely with insecureSkipVerify.
|
||||
func tlsClientConfigFromJS(defaultServerName string, opts js.Value) (*tls.Config, error) {
|
||||
cfg := &tls.Config{
|
||||
ServerName: defaultServerName,
|
||||
RootCAs: bakedroots.Get(),
|
||||
}
|
||||
if !opts.IsUndefined() && !opts.IsNull() {
|
||||
if sn := opts.Get("serverName"); sn.Type() == js.TypeString {
|
||||
cfg.ServerName = sn.String()
|
||||
}
|
||||
if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean {
|
||||
cfg.InsecureSkipVerify = iv.Bool()
|
||||
}
|
||||
if ca := opts.Get("caCerts"); ca.Type() == js.TypeString {
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM([]byte(ca.String())) {
|
||||
return nil, fmt.Errorf("caCerts: no valid PEM certificates found")
|
||||
}
|
||||
cfg.RootCAs = pool
|
||||
}
|
||||
}
|
||||
if cfg.ServerName == "" && !cfg.InsecureSkipVerify {
|
||||
return nil, fmt.Errorf("serverName is required unless insecureSkipVerify is set")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@@ -920,28 +733,9 @@ func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
|
||||
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
|
||||
}
|
||||
|
||||
// On wasm there's no system root pool, so default to the
|
||||
// baked-in LetsEncrypt roots (which is what `tailscale cert`
|
||||
// uses for tailnet HTTPS endpoints). Callers can override with
|
||||
// caCerts (PEM) or bypass entirely with insecureSkipVerify.
|
||||
cfg := &tls.Config{
|
||||
ServerName: host,
|
||||
RootCAs: bakedroots.Get(),
|
||||
}
|
||||
if !opts.IsUndefined() && !opts.IsNull() {
|
||||
if sn := opts.Get("serverName"); sn.Type() == js.TypeString {
|
||||
cfg.ServerName = sn.String()
|
||||
}
|
||||
if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean {
|
||||
cfg.InsecureSkipVerify = iv.Bool()
|
||||
}
|
||||
if ca := opts.Get("caCerts"); ca.Type() == js.TypeString {
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM([]byte(ca.String())) {
|
||||
return nil, fmt.Errorf("caCerts: no valid PEM certificates found")
|
||||
}
|
||||
cfg.RootCAs = pool
|
||||
}
|
||||
cfg, err := tlsClientConfigFromJS(host, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawConn, err := i.dialer.UserDial(ctx, "tcp", addr)
|
||||
@@ -1320,10 +1114,10 @@ func (i *jsIPN) ping(ip string, pingType string, size int) js.Value {
|
||||
return nil, fmt.Errorf("ping: invalid IP %q: %w", ip, err)
|
||||
}
|
||||
switch tailcfg.PingType(pingType) {
|
||||
case tailcfg.PingDisco, tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
|
||||
case tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
|
||||
// valid
|
||||
default:
|
||||
return nil, fmt.Errorf("ping: unknown type %q, must be one of: disco, TSMP, ICMP, peerapi", pingType)
|
||||
return nil, fmt.Errorf("ping: unknown type %q, must be one of: TSMP, ICMP, peerapi", pingType)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -1373,6 +1167,39 @@ func (i *jsIPN) suggestExitNode() js.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) setServices(jsServices js.Value) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
n := jsServices.Length()
|
||||
svcs := make([]tailcfg.Service, 0, n)
|
||||
for idx := range n {
|
||||
s := jsServices.Index(idx)
|
||||
proto := tailcfg.ServiceProto(s.Get("proto").String())
|
||||
port := uint16(s.Get("port").Int())
|
||||
var desc string
|
||||
if d := s.Get("description"); d.Type() == js.TypeString {
|
||||
desc = d.String()
|
||||
}
|
||||
svcs = append(svcs, tailcfg.Service{Proto: proto, Port: port, Description: desc})
|
||||
}
|
||||
i.lb.SetExplicitServices(svcs)
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// userServicesFromView converts a hostinfo services slice to jsService entries,
|
||||
// filtering out internal peerapi protocol entries (already reflected in peerAPIURL).
|
||||
func userServicesFromView(svcs views.Slice[tailcfg.Service]) []jsService {
|
||||
out := make([]jsService, 0, svcs.Len())
|
||||
for _, s := range svcs.All() {
|
||||
switch s.Proto {
|
||||
case tailcfg.PeerAPI4, tailcfg.PeerAPI6, tailcfg.PeerAPIDNS:
|
||||
continue
|
||||
}
|
||||
out = append(out, jsService{Proto: string(s.Proto), Port: s.Port, Description: s.Description})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (i *jsIPN) localAPI(method, path, body string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
h := localapi.NewHandler(localapi.HandlerConfig{
|
||||
@@ -1452,6 +1279,51 @@ func wrapConn(conn net.Conn) map[string]any {
|
||||
"remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return conn.RemoteAddr().String()
|
||||
}),
|
||||
// upgradeTLS wraps the conn in TLS in place (STARTTLS-style) and
|
||||
// returns a new wrapped conn sharing the same underlying net.Conn;
|
||||
// the old handle must not be used afterward. On any failure —
|
||||
// configuration or handshake — the underlying conn is closed, so
|
||||
// callers can treat every rejection as fatal to the connection.
|
||||
// With isServer, certPem and keyPem are required; otherwise client
|
||||
// options as in dialTLS apply.
|
||||
"upgradeTLS": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
opts := js.Undefined()
|
||||
if len(args) > 0 {
|
||||
opts = args[0]
|
||||
}
|
||||
return makePromise(func() (any, error) {
|
||||
var tlsConn *tls.Conn
|
||||
hasOpts := !opts.IsUndefined() && !opts.IsNull()
|
||||
if hasOpts && opts.Get("isServer").Type() == js.TypeBoolean && opts.Get("isServer").Bool() {
|
||||
certPem := opts.Get("certPem")
|
||||
keyPem := opts.Get("keyPem")
|
||||
if certPem.Type() != js.TypeString || keyPem.Type() != js.TypeString {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("upgradeTLS: certPem and keyPem are required when isServer is set")
|
||||
}
|
||||
cert, err := tls.X509KeyPair([]byte(certPem.String()), []byte(keyPem.String()))
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("upgradeTLS: parsing cert/key: %w", err)
|
||||
}
|
||||
tlsConn = tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||
} else {
|
||||
cfg, err := tlsClientConfigFromJS("", opts)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
tlsConn = tls.Client(conn, cfg)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return wrapConn(tlsConn), nil
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1570,16 +1442,6 @@ func resolveUDPAddr(s string) (*net.UDPAddr, error) {
|
||||
return &net.UDPAddr{IP: ip, Port: port}, nil
|
||||
}
|
||||
|
||||
type termWriter struct {
|
||||
f js.Value
|
||||
}
|
||||
|
||||
func (w termWriter) Write(p []byte) (n int, err error) {
|
||||
r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1)
|
||||
w.f.Invoke(string(r))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// jsIncomingFile is the JSON representation of an in-progress inbound file
|
||||
// transfer sent to the notifyIncomingFiles callback.
|
||||
type jsIncomingFile struct {
|
||||
@@ -1609,12 +1471,19 @@ type jsNetMap struct {
|
||||
LockedOut bool `json:"lockedOut"`
|
||||
}
|
||||
|
||||
type jsService struct {
|
||||
Proto string `json:"proto"`
|
||||
Port uint16 `json:"port"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type jsNetMapNode struct {
|
||||
Name string `json:"name"`
|
||||
Addresses []string `json:"addresses"`
|
||||
MachineKey string `json:"machineKey"`
|
||||
NodeKey string `json:"nodeKey"`
|
||||
PeerAPIURL string `json:"peerAPIURL,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Addresses []string `json:"addresses"`
|
||||
MachineKey string `json:"machineKey"`
|
||||
NodeKey string `json:"nodeKey"`
|
||||
PeerAPIURL string `json:"peerAPIURL,omitempty"`
|
||||
Services []jsService `json:"services"`
|
||||
}
|
||||
|
||||
type jsNetMapSelfNode struct {
|
||||
|
||||
@@ -36,49 +36,36 @@ var baseTags = []string{
|
||||
"omitpemdecrypt",
|
||||
}
|
||||
|
||||
// Keep is the set of feature/featuretags tags the cmd/tsconnect/wasm
|
||||
// build needs LINKED. Every other feature in [featuretags.Features] is
|
||||
// excluded via its ts_omit_ build tag (computed by [Tags]).
|
||||
// Transitive dependencies of entries in Keep are pulled in
|
||||
// automatically via [featuretags.Requires].
|
||||
// Omit is the set of feature/featuretags tags excluded from the
|
||||
// cmd/tsconnect/wasm build via their ts_omit_ build tag (computed by
|
||||
// [Tags]). Everything else in [featuretags.Features] stays linked.
|
||||
//
|
||||
// Upstream uses the opposite polarity here — a small allow-list — because
|
||||
// its wasm client is only an SSH/fetch-in-browser tool. This fork's JS
|
||||
// bridge exposes Taildrop, Taildrive, Funnel/serve, ACME certs, exit node
|
||||
// selection, service advertisement and the peerAPI, so an allow-list is
|
||||
// the wrong default: a missing entry is not a compile error, it is a
|
||||
// feature that silently stops working at runtime (an omitted extension
|
||||
// simply never registers its hooks). Linking everything also matches how
|
||||
// this build behaved before upstream introduced featuretags.
|
||||
//
|
||||
// Adding an entry here grows the wasm bundle. Removing one strips it.
|
||||
// The init() below panics if any entry is unknown to feature/featuretags,
|
||||
// so a rename / removal in that registry fails loudly here.
|
||||
//
|
||||
// Notably absent (server-only or otherwise meaningless in a browser):
|
||||
// - "ssh": controls the SSH *server* (feature/ssh registers
|
||||
// ssh/tailssh). The wasm acts as an SSH *client* using
|
||||
// golang.org/x/crypto/ssh directly; no featuretag gates that.
|
||||
// - "portmapper", "debugportmapper": js/wasm has no UDP sockets,
|
||||
// can't speak NAT-PMP / PCP / UPnP.
|
||||
// - "captiveportal": the browser handles captive portal detection
|
||||
// in front of us.
|
||||
// - "syspolicy": no MDM in a browser.
|
||||
// - "drive", "taildrop", "peerapi*": no local filesystem.
|
||||
// - "clientupdate": no binary self-update.
|
||||
// - "dbus", "resolved", "networkmanager", "iptables", "linkspeed",
|
||||
// "linuxdnsfight", "listenrawdisco", "osrouter", "synology",
|
||||
// "systray", "tundevstats", "wakeonlan": OS integrations not
|
||||
// applicable to a browser-hosted client.
|
||||
// - "aws", "cloud", "kube", "bird", "appconnectors", "conn25",
|
||||
// "relayserver", "serve", "acme", "tap", "tpm", "doctor",
|
||||
// "advertiseroutes", "advertiseexitnode", "useroutes",
|
||||
// "useexitnode": server-side or otherwise out of scope for the
|
||||
// SSH-in-browser / fetch-in-browser use case.
|
||||
var Keep = []featuretags.FeatureTag{
|
||||
"c2n", // control-to-node mechanism the control client invokes
|
||||
"dns", // MagicDNS resolution in-process
|
||||
"health", // ipnstate/ipnlocal reference health warnables pervasively
|
||||
"ipnbus", // notification bus for state/netmap callbacks
|
||||
"logtail", // log upload (browser console + remote)
|
||||
"netstack", // userspace networking; wasm has no kernel TUN
|
||||
// Trimming the bundle by omitting more features is worthwhile but should
|
||||
// be done with measurements and per-feature runtime verification, not by
|
||||
// assuming a feature is unreachable from the browser.
|
||||
var Omit = []featuretags.FeatureTag{
|
||||
// feature/ace does not compile for GOOS=js: control/controlhttp only
|
||||
// installs HookMakeACEDialer on non-js platforms, so feature/ace's
|
||||
// reference to it is undefined here.
|
||||
"ace",
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, ft := range Keep {
|
||||
for _, ft := range Omit {
|
||||
if _, ok := featuretags.Features[ft]; !ok {
|
||||
panic(fmt.Sprintf("wasmbuild.Keep references unknown feature tag %q; "+
|
||||
panic(fmt.Sprintf("wasmbuild.Omit references unknown feature tag %q; "+
|
||||
"did feature/featuretags rename or remove it?", ft))
|
||||
}
|
||||
}
|
||||
@@ -103,25 +90,22 @@ type BuildInfo struct {
|
||||
}
|
||||
|
||||
// Tags returns the joined -tags value for the wasm build: [baseTags]
|
||||
// plus a ts_omit_<feature> for every entry in [featuretags.Features]
|
||||
// that is not transitively required by [Keep].
|
||||
// plus a ts_omit_<feature> for every entry in [Omit].
|
||||
//
|
||||
// The result is sorted so that the same source tree always produces
|
||||
// the same string (and therefore the same wasm bytes, given identical
|
||||
// inputs to `go build`).
|
||||
func Tags() string {
|
||||
keep := map[featuretags.FeatureTag]bool{}
|
||||
for _, ft := range Keep {
|
||||
for dep := range featuretags.Requires(ft) {
|
||||
keep[dep] = true
|
||||
}
|
||||
omit := map[featuretags.FeatureTag]bool{}
|
||||
for _, ft := range Omit {
|
||||
omit[ft] = true
|
||||
}
|
||||
tags := slices.Clone(baseTags)
|
||||
for ft := range featuretags.Features {
|
||||
if ft == "" || !ft.IsOmittable() {
|
||||
continue
|
||||
}
|
||||
if !keep[ft] {
|
||||
if omit[ft] {
|
||||
tags = append(tags, ft.OmitTag())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,12 @@ func (c *Auto) restartMap() {
|
||||
c.updateControl()
|
||||
}
|
||||
|
||||
// RestartMap cancels the existing map poll and starts a fresh streaming one,
|
||||
// forcing the control server to send a new full netmap response.
|
||||
func (c *Auto) RestartMap() {
|
||||
c.restartMap()
|
||||
}
|
||||
|
||||
func (c *Auto) authRoutine() {
|
||||
defer close(c.authDone)
|
||||
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
|
||||
|
||||
+35
-1
@@ -337,6 +337,7 @@ type LocalBackend struct {
|
||||
capTailnetLock bool // whether netMap contains the tailnet lock capability
|
||||
// hostinfo is mutated in-place while mu is held.
|
||||
hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend
|
||||
explicitServices []tailcfg.Service // services set explicitly via SetExplicitServices; always uploaded
|
||||
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend
|
||||
activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]).
|
||||
engineStatus ipn.EngineStatus
|
||||
@@ -1762,6 +1763,13 @@ func (b *LocalBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap {
|
||||
return b.currentNode().PeerCaps(src)
|
||||
}
|
||||
|
||||
// PeerCapsIncludingUnsigned is like [LocalBackend.PeerCaps] but does not deny
|
||||
// capabilities to peers with UnsignedPeerAPIOnly set. It exists only for the
|
||||
// Funnel ingress path; see [nodeBackend.PeerCapsIncludingUnsigned].
|
||||
func (b *LocalBackend) PeerCapsIncludingUnsigned(src netip.Addr) tailcfg.PeerCapMap {
|
||||
return b.currentNode().PeerCapsIncludingUnsigned(src)
|
||||
}
|
||||
|
||||
// PeerCapsForIP returns the capabilities that remote src IP has when
|
||||
// talking to the given destination IP on this node.
|
||||
func (b *LocalBackend) PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap {
|
||||
@@ -5681,6 +5689,30 @@ func (b *LocalBackend) setPortlistServices(sl []tailcfg.Service) {
|
||||
b.doSetHostinfoFilterServices()
|
||||
}
|
||||
|
||||
// SetExplicitServices sets the services this node advertises on the netmap.
|
||||
// Unlike the OS port-scan path (setPortlistServices), services set here are
|
||||
// always uploaded to the control server regardless of the ShouldUploadServices
|
||||
// hook — suitable for environments like browser WASM where OS port scanning is
|
||||
// unavailable and services are declared programmatically.
|
||||
func (b *LocalBackend) SetExplicitServices(sl []tailcfg.Service) {
|
||||
b.mu.Lock()
|
||||
if b.hostinfo == nil {
|
||||
b.hostinfo = new(tailcfg.Hostinfo)
|
||||
}
|
||||
b.hostinfo.Services = sl
|
||||
b.explicitServices = sl
|
||||
ccAuto := b.ccAuto
|
||||
b.mu.Unlock()
|
||||
|
||||
b.doSetHostinfoFilterServices()
|
||||
// Restart the streaming map poll so the control server sends back a fresh
|
||||
// netmap that includes our updated services in SelfNode, and so peers
|
||||
// receive the update promptly via the control server's push.
|
||||
if ccAuto != nil {
|
||||
ccAuto.RestartMap()
|
||||
}
|
||||
}
|
||||
|
||||
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
|
||||
// possibly after mangling the given hostinfo.
|
||||
//
|
||||
@@ -5725,7 +5757,9 @@ func (b *LocalBackend) hostInfoWithServicesLocked() *tailcfg.Hostinfo {
|
||||
// Make a shallow copy of hostinfo so we can mutate
|
||||
// at the Service field.
|
||||
if f, ok := b.extHost.Hooks().ShouldUploadServices.GetOk(); !ok || !f() {
|
||||
hi.Services = []tailcfg.Service{}
|
||||
if len(b.explicitServices) == 0 {
|
||||
hi.Services = []tailcfg.Service{}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't mutate hi.Service's underlying array. Append to
|
||||
|
||||
@@ -432,10 +432,32 @@ func (nb *nodeBackend) srcIsUnsignedPeerLocked(src netip.Addr) bool {
|
||||
return ok && n.UnsignedPeerAPIOnly()
|
||||
}
|
||||
|
||||
// PeerCapsIncludingUnsigned is like [nodeBackend.PeerCaps] but does not deny
|
||||
// capabilities to peers with UnsignedPeerAPIOnly set.
|
||||
//
|
||||
// Funnel ingress relays are delivered as UnsignedPeerAPIOnly nodes: per the
|
||||
// docs on [tailcfg.Node.UnsignedPeerAPIOnly] they get no network access at all
|
||||
// and exist solely to reach this node's peerapi. The ingress endpoint they need
|
||||
// is gated on [tailcfg.PeerCapabilityIngress], so denying them capabilities
|
||||
// wholesale — as peerCapsLocked does upstream as of 0eb38dc2e — makes Funnel
|
||||
// impossible. Callers must therefore be limited to the ingress path.
|
||||
//
|
||||
// This is a fork-local patch; drop it once upstream restores Funnel.
|
||||
// See webnet/tailscale#16.
|
||||
func (nb *nodeBackend) PeerCapsIncludingUnsigned(src netip.Addr) tailcfg.PeerCapMap {
|
||||
nb.mu.Lock()
|
||||
defer nb.mu.Unlock()
|
||||
return nb.peerCapsIgnoringSignatureLocked(src)
|
||||
}
|
||||
|
||||
func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
|
||||
if nb.srcIsUnsignedPeerLocked(src) {
|
||||
return nil
|
||||
}
|
||||
return nb.peerCapsIgnoringSignatureLocked(src)
|
||||
}
|
||||
|
||||
func (nb *nodeBackend) peerCapsIgnoringSignatureLocked(src netip.Addr) tailcfg.PeerCapMap {
|
||||
if nb.netMap == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -592,8 +592,14 @@ func (h *peerAPIHandler) canDebug() bool {
|
||||
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
|
||||
|
||||
// canIngress reports whether h can send ingress requests to this node.
|
||||
//
|
||||
// The ingress cap is resolved without the unsigned-peer denial that
|
||||
// [nodeBackend.PeerCaps] applies, because Funnel ingress relays are by design
|
||||
// UnsignedPeerAPIOnly nodes whose only permitted action is this endpoint.
|
||||
// See [nodeBackend.PeerCapsIncludingUnsigned].
|
||||
func (h *peerAPIHandler) canIngress() bool {
|
||||
return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
|
||||
caps := h.ps.b.PeerCapsIncludingUnsigned(h.remoteAddr.Addr())
|
||||
return caps.HasCapability(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
|
||||
|
||||
@@ -5,15 +5,22 @@ package safesocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/akutz/memconn"
|
||||
)
|
||||
|
||||
const memName = "Tailscale-IPN"
|
||||
|
||||
// memSeq ensures each IPN instance in the same WASM process gets a distinct
|
||||
// memconn address, so concurrent instances do not conflict on the registry.
|
||||
var memSeq atomic.Int64
|
||||
|
||||
func listen(path string) (net.Listener, error) {
|
||||
return memconn.Listen("memu", memName)
|
||||
name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1))
|
||||
return memconn.Listen("memu", name)
|
||||
}
|
||||
|
||||
func connect(ctx context.Context, _ string) (net.Conn, error) {
|
||||
|
||||
Reference in New Issue
Block a user