diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10f4c1366..e9f581860 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,8 +15,19 @@ jobs: with: go-version-file: go.mod - run: go build ./... - - run: go test -count=1 -timeout 120s ./... + - run: go test -count=1 -timeout 300s ./... - run: go vet ./... + libtailcat: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + - name: build c-archive and c-shared libraries + run: make -C libtailcat all + - name: C bindings integration test + run: go test -count=1 -timeout 300s ./libtailcat/... wasm: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index e958b42dd..1a683c95c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,12 @@ /tailcat /cmd/tailcat/tailcat +# libtailcat build artifacts. libtailcat.h is generated by cgo; +# the source of truth header is libtailcat/tailcat.h. +/libtailcat/libtailcat.h +/libtailcat/libtailcat.a +/libtailcat/libtailcat.so + # Go test artifacts. *.test *.out diff --git a/libtailcat/Makefile b/libtailcat/Makefile new file mode 100644 index 000000000..1e9dc3927 --- /dev/null +++ b/libtailcat/Makefile @@ -0,0 +1,29 @@ +# Copyright (c) Tailscale Inc & contributors +# SPDX-License-Identifier: BSD-3-Clause + +export CGO_ENABLED=1 + +# The library targets are phony so that source changes always +# rebuild them; Go's build cache keeps that cheap. +.PHONY: libtailcat.a libtailcat.so + +libtailcat.a: ## Build the static c-archive library + go build -buildmode=c-archive -o $@ + +libtailcat.so: ## Build the shared c-shared library + go build -buildmode=c-shared -o $@ + +.PHONY: c-archive +c-archive: libtailcat.a + +.PHONY: shared +shared: libtailcat.so + +.PHONY: all +all: c-archive shared + +.PHONY: clean +clean: ## Remove build artifacts, including the cgo-generated header + rm -f libtailcat.h libtailcat.a libtailcat.so + +.DEFAULT_GOAL := all diff --git a/libtailcat/ctest/ctest.go b/libtailcat/ctest/ctest.go new file mode 100644 index 000000000..1395a93f0 --- /dev/null +++ b/libtailcat/ctest/ctest.go @@ -0,0 +1,322 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package ctest tests the libtailcat C bindings. +// +// It is used by libtailcat's lib_test.go, because the 'import "C"' +// directive is not allowed in test files. +package ctest + +/* +#include +#include +#include +#include +#include +#include +#include +#include "../tailcat.h" + +char* derpmap_url = 0; + +enum { errlen = 1024 }; +char* err = NULL; + +tailcat_server srv; +tailcat_client cl; + +static int set_err(int handle, char tag) { + err[0] = tag; + err[1] = ':'; + err[2] = ' '; + tailcat_errmsg(handle, &err[3], errlen-3); + return 1; +} + +// wait_event waits (up to ~10s) for an event whose "type" field is +// want_type on the given handle, discarding other events. +static int wait_event(int handle, const char* want_type) { + int fd = tailcat_events_fd(handle); + if (fd < 0) { + snprintf(err, errlen, "wait_event(%s): bad handle", want_type); + return 1; + } + char match[64]; + snprintf(match, sizeof(match), "\"type\":\"%s\"", want_type); + char ev[512]; + for (;;) { + int ret = tailcat_event_next(handle, ev, sizeof(ev)); + if (ret == 0) { + if (strstr(ev, match) != NULL) { + return 0; + } + continue; // some other event; keep looking + } + if (ret != EAGAIN) { + snprintf(err, errlen, "event_next(%s) = %d", want_type, ret); + return 1; + } + struct pollfd pfd = {.fd = fd, .events = POLLIN}; + int n = poll(&pfd, 1, 10000); + if (n <= 0) { + snprintf(err, errlen, "timeout waiting for %s event", want_type); + return 1; + } + char hints[64]; + ssize_t discarded = read(fd, hints, sizeof(hints)); // discard wakeup hints + (void)discarded; + } +} + +// read_full reads exactly n bytes from fd into buf. +static int read_full(int fd, char* buf, size_t n) { + size_t got = 0; + while (got < n) { + ssize_t r = read(fd, buf+got, n-got); + if (r <= 0) { + snprintf(err, errlen, "read(fd %d): %zd after %zu bytes, errno %d (%s)", + fd, r, got, errno, strerror(errno)); + return 1; + } + got += r; + } + return 0; +} + +int test_conn() { + err = calloc(errlen, 1); + int ret; + + char priv[128]; + if ((ret = tailcat_keypair_new(priv, sizeof(priv))) != 0) { + snprintf(err, errlen, "keypair_new = %d", ret); + return 1; + } + char pub[128]; + if ((ret = tailcat_pubkey(priv, pub, sizeof(pub))) != 0) { + snprintf(err, errlen, "pubkey = %d", ret); + return 1; + } + if (strncmp(pub, "nodekey:", 8) != 0) { + snprintf(err, errlen, "pubkey %s doesn't start with nodekey:", pub); + return 1; + } + + srv = tailcat_server_new(priv); + if (srv == 0) { + snprintf(err, errlen, "server_new failed"); + return 1; + } + if ((ret = tailcat_server_set_derpmap_url(srv, derpmap_url)) != 0) { + return set_err(srv, '0'); + } + if ((ret = tailcat_server_set_region_id(srv, 1)) != 0) { + return set_err(srv, '1'); + } + if ((ret = tailcat_server_set_logfd(srv, -1)) != 0) { + return set_err(srv, '2'); + } + + // Listen before start, to exercise that ordering. + tailcat_listener ln; + if ((ret = tailcat_server_listen(srv, 8081, &ln)) != 0) { + return set_err(srv, '3'); + } + if ((ret = tailcat_server_listen(srv, 8081, &ln)) == 0) { + snprintf(err, errlen, "duplicate listen unexpectedly succeeded"); + return 1; + } + + if ((ret = tailcat_server_start(srv)) != 0) { + return set_err(srv, '4'); + } + + char blob[1024]; + if ((ret = tailcat_server_connblob(srv, blob, sizeof(blob))) != 0) { + return set_err(srv, '5'); + } + if (strncmp(blob, "tc", 2) != 0) { + snprintf(err, errlen, "connblob %.32s doesn't start with tc", blob); + return 1; + } + + cl = tailcat_client_new(blob, NULL); + if (cl == 0) { + snprintf(err, errlen, "client_new failed"); + return 1; + } + if ((ret = tailcat_client_set_derpmap_url(cl, derpmap_url)) != 0) { + return set_err(cl, '6'); + } + if ((ret = tailcat_client_set_logfd(cl, -1)) != 0) { + return set_err(cl, '7'); + } + + // The handshake packet can be lost if the server's relay + // connection is still coming up, so allow one retry. + double latency_ms = 0; + if ((ret = tailcat_client_connect(cl, &latency_ms)) != 0) { + if ((ret = tailcat_client_connect(cl, &latency_ms)) != 0) { + return set_err(cl, '8'); + } + } + if (latency_ms <= 0) { + snprintf(err, errlen, "latency_ms = %f, want > 0", latency_ms); + return 1; + } + if (wait_event(cl, "connected") != 0) { + return 1; + } + if (wait_event(srv, "client-connected") != 0) { + return 1; + } + + tailcat_conn w; + if ((ret = tailcat_client_dial(cl, 8081, &w)) != 0) { + return set_err(cl, '9'); + } + tailcat_conn r; + if ((ret = tailcat_accept(ln, &r)) != 0) { + return set_err(srv, 'a'); + } + if (wait_event(cl, "dial-ok") != 0) { + return 1; + } + + // Client to server. + const char hello[] = "hello"; + if (write(w, hello, strlen(hello)) != (ssize_t)strlen(hello)) { + snprintf(err, errlen, "short write: errno %d (%s)", errno, strerror(errno)); + return 1; + } + char got[16]; + if (read_full(r, got, strlen(hello)) != 0) { + return 1; + } + if (strncmp(got, hello, strlen(hello)) != 0) { + snprintf(err, errlen, "got %.5s, want %s", got, hello); + return 1; + } + + // Server to client. + const char world[] = "world"; + if (write(r, world, strlen(world)) != (ssize_t)strlen(world)) { + snprintf(err, errlen, "short write back: errno %d (%s)", errno, strerror(errno)); + return 1; + } + if (read_full(w, got, strlen(world)) != 0) { + return 1; + } + if (strncmp(got, world, strlen(world)) != 0) { + snprintf(err, errlen, "got %.5s, want %s", got, world); + return 1; + } + + // Half-close: after the client shuts down its write side, the + // server sees EOF but can still send, netcat style. + if (shutdown(w, SHUT_WR) != 0) { + snprintf(err, errlen, "shutdown: errno %d (%s)", errno, strerror(errno)); + return 1; + } + if (read(r, got, sizeof(got)) != 0) { + snprintf(err, errlen, "no EOF after half-close"); + return 1; + } + const char bye[] = "bye!"; + if (write(r, bye, strlen(bye)) != (ssize_t)strlen(bye)) { + snprintf(err, errlen, "write after half-close: errno %d (%s)", errno, strerror(errno)); + return 1; + } + if (read_full(w, got, strlen(bye)) != 0) { + return 1; + } + if (strncmp(got, bye, strlen(bye)) != 0) { + snprintf(err, errlen, "got %.4s, want %s", got, bye); + return 1; + } + + if (close(w) != 0 || close(r) != 0) { + snprintf(err, errlen, "close conns: errno %d (%s)", errno, strerror(errno)); + return 1; + } + + // Dialing a port with no listener fails with a dial-error event + // and EOF on the connection. + tailcat_conn bad; + if ((ret = tailcat_client_dial(cl, 9, &bad)) != 0) { + return set_err(cl, 'b'); + } + if (wait_event(cl, "dial-error") != 0) { + return 1; + } + if (read(bad, got, sizeof(got)) != 0) { + snprintf(err, errlen, "no EOF on failed dial"); + return 1; + } + if (close(bad) != 0) { + snprintf(err, errlen, "close bad conn: errno %d (%s)", errno, strerror(errno)); + return 1; + } + + if (close(ln) != 0) { + snprintf(err, errlen, "close listener: errno %d (%s)", errno, strerror(errno)); + return 1; + } + return 0; +} + +int close_conn() { + if (tailcat_client_close(cl) != 0) { + return set_err(cl, 'c'); + } + if (tailcat_client_close(cl) != EBADF) { + snprintf(err, errlen, "double client close didn't return EBADF"); + return 1; + } + if (tailcat_server_close(srv) != 0) { + return set_err(srv, 'd'); + } + if (tailcat_server_close(srv) != EBADF) { + snprintf(err, errlen, "double server close didn't return EBADF"); + return 1; + } + return 0; +} +*/ +import "C" +import ( + "encoding/json" + "flag" + "net/http" + "net/http/httptest" + "testing" + + "tailscale.com/tstest/integration" + "tailscale.com/types/logger" +) + +var verboseDERP = flag.Bool("verbose-derp", false, "if set, print DERP and STUN logs") + +// RunTestConn runs a local DERP relay and a DERP map server for it, +// then drives the C side of the test: a tailcat server and client +// exchanging data both ways through the relay via the C API. +func RunTestConn(t *testing.T) { + derpLogf := logger.Discard + if *verboseDERP { + derpLogf = t.Logf + } + dm := integration.RunDERPAndSTUN(t, derpLogf, "127.0.0.1") + + dms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(dm) + })) + t.Cleanup(dms.Close) + C.derpmap_url = C.CString(dms.URL) + + if C.test_conn() != 0 { + t.Fatal(C.GoString(C.err)) + } + if C.close_conn() != 0 { + t.Fatal(C.GoString(C.err)) + } +} diff --git a/libtailcat/lib_test.go b/libtailcat/lib_test.go new file mode 100644 index 000000000..906be09c1 --- /dev/null +++ b/libtailcat/lib_test.go @@ -0,0 +1,41 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "testing" + "time" + + "github.com/tailscale/tailcat/libtailcat/ctest" +) + +func TestConn(t *testing.T) { + ctest.RunTestConn(t) + waitDrained(t) +} + +// waitDrained waits for the C test's teardown to empty all the +// bookkeeping maps, catching leaked handles, listeners, or conns. +func waitDrained(t *testing.T) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + handles.mu.Lock() + nh := len(handles.m) + handles.mu.Unlock() + listeners.mu.Lock() + nl := len(listeners.m) + listeners.mu.Unlock() + conns.mu.Lock() + nc := len(conns.m) + conns.mu.Unlock() + if nh == 0 && nl == 0 && nc == 0 { + return + } + if time.Now().After(deadline) { + t.Fatalf("leaked: %d handles, %d listeners, %d conns", nh, nl, nc) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/libtailcat/libtailcat.go b/libtailcat/libtailcat.go new file mode 100644 index 000000000..4b64c0c8e --- /dev/null +++ b/libtailcat/libtailcat.go @@ -0,0 +1,834 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// A Go c-archive of the tailcat package. See tailcat.h for details. +package main + +//#include +import "C" + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "sync" + "syscall" + "time" + "unsafe" + + "github.com/tailscale/tailcat" + "tailscale.com/types/key" + "tailscale.com/types/logger" +) + +func main() {} + +// handles tracks all the allocated server and client objects. The +// values are *tcServer or *tcClient. Handle values start well above +// the range of plausible file descriptors so that mixing one up with +// an fd fails fast. +var handles struct { + mu sync.Mutex + next C.int + m map[C.int]any +} + +func newHandle(v any) C.int { + handles.mu.Lock() + defer handles.mu.Unlock() + if handles.m == nil { + handles.m = map[C.int]any{} + handles.next = 42<<16 + 1 + } + h := handles.next + handles.next++ + handles.m[h] = v + return h +} + +func getServer(h C.int) *tcServer { + handles.mu.Lock() + defer handles.mu.Unlock() + s, _ := handles.m[h].(*tcServer) + return s +} + +func getClient(h C.int) *tcClient { + handles.mu.Lock() + defer handles.mu.Unlock() + c, _ := handles.m[h].(*tcClient) + return c +} + +// getState returns the common state of the server or client handle h, +// or nil if h is not a valid handle. +func getState(h C.int) *handleState { + handles.mu.Lock() + defer handles.mu.Unlock() + switch v := handles.m[h].(type) { + case *tcServer: + return &v.handleState + case *tcClient: + return &v.handleState + } + return nil +} + +func deleteHandle(h C.int) any { + handles.mu.Lock() + defer handles.mu.Unlock() + v := handles.m[h] + delete(handles.m, h) + return v +} + +// handleState is the state common to server and client handles: the +// last error message and the event queue. +type handleState struct { + errMu sync.Mutex + lastErr string + + ev events +} + +func (h *handleState) recErr(err error) C.int { + h.errMu.Lock() + defer h.errMu.Unlock() + if err == nil { + h.lastErr = "" + return 0 + } + h.lastErr = err.Error() + return -1 +} + +// events is a queue of pending JSON-encoded events plus a socketpair +// whose C-visible end becomes readable when events are queued. Bytes +// on the socketpair are wakeup hints only, not exact event counts; +// after draining them, C should call tailcat_event_next until it +// returns EAGAIN. +type events struct { + mu sync.Mutex + queue []string + goFD int // Go writes wakeup hint bytes here (nonblocking) + cFD C.int // C reads hint bytes here +} + +func (e *events) init() error { + fds, err := syscall.Socketpair(syscall.AF_LOCAL, syscall.SOCK_STREAM, 0) + if err != nil { + return err + } + if err := syscall.SetNonblock(fds[1], true); err != nil { + syscall.Close(fds[0]) + syscall.Close(fds[1]) + return err + } + e.cFD = C.int(fds[0]) + e.goFD = fds[1] + return nil +} + +func (e *events) enqueue(v map[string]any) { + j, err := json.Marshal(v) + if err != nil { + panic(err) // events are built from marshalable types above + } + e.mu.Lock() + e.queue = append(e.queue, string(j)) + e.mu.Unlock() + // Best effort: if the buffer is full (C isn't reading), earlier + // unread hint bytes already mark the fd readable. + syscall.Write(e.goFD, []byte{0}) +} + +// close closes the Go side of the event socketpair, which makes the C +// side readable with EOF: the signal that the handle was closed. +func (e *events) close() { + syscall.Close(e.goFD) +} + +// tcServer is the state behind a tailcat_server handle. +type tcServer struct { + handleState + + mu sync.Mutex + priv key.NodePrivate + derpmapURL string + regionID int + logf logger.Logf + srv *tailcat.Server + blob tailcat.ConnBlob + started bool + + // portsMu guards ports. It is separate from mu because the OnTCP + // dispatcher consults ports from netstack while Start may be + // holding mu. + portsMu sync.Mutex + ports map[uint16]*listener +} + +func (s *tcServer) logfSafe() logger.Logf { + s.mu.Lock() + defer s.mu.Unlock() + return s.logf +} + +// tcClient is the state behind a tailcat_client handle. +type tcClient struct { + handleState + + cl *tailcat.Client + done chan struct{} // closed by TailcatClientClose + + mu sync.Mutex + logf logger.Logf +} + +// parsePrivateKey parses the text form of a node private key +// ("privkey:..."), as produced by TailcatKeypairNew. A nil or empty +// string generates a new key. +func parsePrivateKey(cstr *C.char) (key.NodePrivate, error) { + var priv key.NodePrivate + if cstr == nil || *cstr == 0 { + return key.NewNode(), nil + } + if err := priv.UnmarshalText([]byte(C.GoString(cstr))); err != nil { + return priv, err + } + return priv, nil +} + +// copyOut copies s into the C buffer buf of size buflen, +// NUL-terminating it. It returns 0 or ERANGE if s doesn't fit. +func copyOut(s string, buf *C.char, buflen C.size_t) C.int { + if buf == nil || buflen == 0 { + panic("nil or empty output buffer") + } + out := unsafe.Slice((*byte)(unsafe.Pointer(buf)), buflen) + n := copy(out, s) + if n >= len(out) { + out[len(out)-1] = '\x00' // always NUL-terminate + return C.ERANGE + } + out[n] = '\x00' + return 0 +} + +//export TailcatKeypairNew +func TailcatKeypairNew(buf *C.char, buflen C.size_t) C.int { + priv := key.NewNode() + txt, err := priv.MarshalText() + if err != nil { + return -1 + } + return copyOut(string(txt), buf, buflen) +} + +//export TailcatPubkey +func TailcatPubkey(privkey *C.char, buf *C.char, buflen C.size_t) C.int { + var priv key.NodePrivate + if err := priv.UnmarshalText([]byte(C.GoString(privkey))); err != nil { + copyOut("", buf, buflen) + return -1 + } + return copyOut(priv.Public().String(), buf, buflen) +} + +//export TailcatServerNew +func TailcatServerNew(privkey *C.char) C.int { + priv, err := parsePrivateKey(privkey) + if err != nil { + return 0 + } + s := &tcServer{ + priv: priv, + regionID: -1, // auto-select by latency + logf: logger.Discard, + } + if err := s.ev.init(); err != nil { + return 0 + } + return newHandle(s) +} + +//export TailcatServerSetDerpmapURL +func TailcatServerSetDerpmapURL(h C.int, url *C.char) C.int { + s := getServer(h) + if s == nil { + return C.EBADF + } + s.mu.Lock() + defer s.mu.Unlock() + s.derpmapURL = C.GoString(url) + return 0 +} + +//export TailcatServerSetRegionID +func TailcatServerSetRegionID(h C.int, regionID C.int) C.int { + s := getServer(h) + if s == nil { + return C.EBADF + } + s.mu.Lock() + defer s.mu.Unlock() + s.regionID = int(regionID) + return 0 +} + +//export TailcatServerSetLogFD +func TailcatServerSetLogFD(h, fd C.int) C.int { + s := getServer(h) + if s == nil { + return C.EBADF + } + s.mu.Lock() + defer s.mu.Unlock() + s.logf = logfForFD(fd) + return 0 +} + +func logfForFD(fd C.int) logger.Logf { + if fd == -1 { + return logger.Discard + } + f := os.NewFile(uintptr(fd), "logfd") + return func(format string, args ...any) { + fmt.Fprintf(f, format, args...) + fmt.Fprintf(f, "\n") + } +} + +//export TailcatServerStart +func TailcatServerStart(h C.int) C.int { + s := getServer(h) + if s == nil { + return C.EBADF + } + s.mu.Lock() + defer s.mu.Unlock() + if s.started { + return s.recErr(fmt.Errorf("libtailcat: server already started")) + } + + ci := &tailcat.ConnInfo{ + ServerPublic: tailcat.NodePublic{NodePublic: s.priv.Public()}, + RegionID: s.regionID, + } + opts := []any{tailcat.ExpandForServer} + if s.derpmapURL != "" { + opts = append(opts, tailcat.DERPMapURL(s.derpmapURL)) + } + if err := ci.Expand(context.Background(), opts...); err != nil { + return s.recErr(err) + } + if len(ci.Region) == 0 { + return s.recErr(fmt.Errorf("libtailcat: no DERP region resolved")) + } + reg := ci.Region[0] + + // The blob references the relay region by ID; clients resolve it + // from their own DERP map URL. + s.blob = (&tailcat.ConnInfo{ + ServerPublic: ci.ServerPublic, + RegionID: reg.RegionID, + }).ConnBlob() + + srv, err := tailcat.NewServer(s.priv, s.logf, reg) + if err != nil { + return s.recErr(err) + } + srv.OnTCP = func(port uint16) func(net.Conn) { + s.portsMu.Lock() + l := s.ports[port] + s.portsMu.Unlock() + if l == nil { + return nil // RST + } + return l.handle + } + srv.OnClientConnect = func(k key.NodePublic) { + s.ev.enqueue(map[string]any{ + "type": "client-connected", + "key": k.String(), + }) + } + if err := srv.Start(); err != nil { + return s.recErr(err) + } + // Wait for the relay connection so that clients given the + // ConnBlob right after this returns can reach us immediately. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.WaitDERPConnected(ctx); err != nil { + srv.Close() + return s.recErr(fmt.Errorf("waiting for relay connection: %w", err)) + } + s.srv = srv + s.started = true + return 0 +} + +//export TailcatServerConnblob +func TailcatServerConnblob(h C.int, buf *C.char, buflen C.size_t) C.int { + s := getServer(h) + if s == nil { + copyOut("", buf, buflen) + return C.EBADF + } + s.mu.Lock() + defer s.mu.Unlock() + if !s.started { + copyOut("", buf, buflen) + return s.recErr(fmt.Errorf("libtailcat: server not started")) + } + return copyOut(string(s.blob), buf, buflen) +} + +// listeners tracks all the listener socketpairs allocated via +// TailcatServerListen, keyed by the fd given to C. +var listeners struct { + mu sync.Mutex + m map[C.int]*listener +} + +// listener accepts connections for one TCP port of a server. It is +// one side of a socketpair: accepted connection fds are passed to the +// C side via SCM_RIGHTS, so the C side can epoll its end to learn +// when a connection is ready to accept. +type listener struct { + s *tcServer + port uint16 + fd int // Go side of the socketpair + cFD C.int // C side of the socketpair +} + +//export TailcatServerListen +func TailcatServerListen(h C.int, port C.int, listenerOut *C.int) C.int { + s := getServer(h) + if s == nil { + return C.EBADF + } + if port < 0 || port > 65535 { + return s.recErr(fmt.Errorf("libtailcat: invalid port %d", port)) + } + + fds, err := syscall.Socketpair(syscall.AF_LOCAL, syscall.SOCK_STREAM, 0) + if err != nil { + return s.recErr(err) + } + l := &listener{s: s, port: uint16(port), fd: fds[1], cFD: C.int(fds[0])} + + s.portsMu.Lock() + if _, dup := s.ports[uint16(port)]; dup { + s.portsMu.Unlock() + syscall.Close(fds[0]) + syscall.Close(fds[1]) + return s.recErr(fmt.Errorf("libtailcat: port %d already has a listener", port)) + } + if s.ports == nil { + s.ports = map[uint16]*listener{} + } + s.ports[uint16(port)] = l + s.portsMu.Unlock() + + listeners.mu.Lock() + if listeners.m == nil { + listeners.m = map[C.int]*listener{} + } + listeners.m[l.cFD] = l + listeners.mu.Unlock() + + go func() { + // The C side never writes, so this read blocks until C closes + // its end of the socketpair: the signal to tear down. + var buf [16]byte + syscall.Read(l.fd, buf[:]) + l.close() + }() + + *listenerOut = l.cFD + return 0 +} + +// close tears down the listener, removing it from its server's port +// map and the global listener map. It is safe to call twice: the fd +// is closed only by whichever call removes it from the map. +func (l *listener) close() { + l.s.portsMu.Lock() + if l.s.ports[l.port] == l { + delete(l.s.ports, l.port) + } + l.s.portsMu.Unlock() + + listeners.mu.Lock() + cur, ok := listeners.m[l.cFD] + if ok && cur == l { + delete(listeners.m, l.cFD) + syscall.Close(l.fd) + } + listeners.mu.Unlock() +} + +// handle is the tailcat OnTCP handler for one accepted connection on +// this listener's port: it wraps the connection in a socketpair and +// passes the C-side fd through the listener socketpair via SCM_RIGHTS +// for TailcatAccept (or the C caller's own recvmsg) to pick up. +func (l *listener) handle(netConn net.Conn) { + c, connFD, err := newConn() + if err != nil { + l.s.logfSafe()("libtailcat: newConn: %v", err) + netConn.Close() + return + } + // One byte of real data accompanies the rights; SOCK_STREAM + // ancillary data is not reliably delivered with zero-length + // messages, and the byte also marks the fd readable for poll. + rights := syscall.UnixRights(int(connFD)) + if err := syscall.Sendmsg(l.fd, []byte{0}, rights, nil, 0); err != nil { + l.s.logfSafe()("libtailcat: sendmsg: %v", err) + netConn.Close() + c.cleanup() + syscall.Close(int(connFD)) + return + } + syscall.Close(int(connFD)) // now owned by the recvmsg side + c.start(netConn) +} + +//export TailcatAccept +func TailcatAccept(listenerFD C.int, connOut *C.int) C.int { + listeners.mu.Lock() + l := listeners.m[listenerFD] + listeners.mu.Unlock() + if l == nil { + return C.EBADF + } + + data := make([]byte, 1) + oob := make([]byte, syscall.CmsgSpace(4)) + _, oobn, _, _, err := syscall.Recvmsg(int(listenerFD), data, oob, 0) + if err != nil { + return l.s.recErr(err) + } + scms, err := syscall.ParseSocketControlMessage(oob[:oobn]) + if err != nil { + return l.s.recErr(err) + } + if len(scms) != 1 { + return l.s.recErr(fmt.Errorf("libtailcat: got %d control messages, want 1", len(scms))) + } + fds, err := syscall.ParseUnixRights(&scms[0]) + if err != nil { + return l.s.recErr(err) + } + if len(fds) != 1 { + return l.s.recErr(fmt.Errorf("libtailcat: got %d fds, want 1", len(fds))) + } + *connOut = C.int(fds[0]) + return 0 +} + +//export TailcatServerClose +func TailcatServerClose(h C.int) C.int { + v := deleteHandle(h) + s, ok := v.(*tcServer) + if !ok { + return C.EBADF + } + + s.portsMu.Lock() + ls := make([]*listener, 0, len(s.ports)) + for _, l := range s.ports { + ls = append(ls, l) + } + s.portsMu.Unlock() + + s.mu.Lock() + srv := s.srv + s.mu.Unlock() + + for _, l := range ls { + l.close() + } + s.ev.close() + if srv != nil { + if err := srv.Close(); err != nil { + return -1 + } + } + return 0 +} + +//export TailcatClientNew +func TailcatClientNew(connblob, privkey *C.char) C.int { + priv, err := parsePrivateKey(privkey) + if err != nil { + return 0 + } + c := &tcClient{ + logf: logger.Discard, + done: make(chan struct{}), + } + // Indirect logf so TailcatClientSetLogFD works after creation. + logf := func(format string, args ...any) { + c.mu.Lock() + f := c.logf + c.mu.Unlock() + f(format, args...) + } + cl, err := tailcat.NewClient(logf, tailcat.ConnBlob(C.GoString(connblob)), priv) + if err != nil { + return 0 + } + if err := c.ev.init(); err != nil { + cl.Close() + return 0 + } + c.cl = cl + go func() { + select { + case <-cl.Connected(): + c.ev.enqueue(map[string]any{"type": "connected"}) + case <-c.done: + } + }() + return newHandle(c) +} + +//export TailcatClientSetDerpmapURL +func TailcatClientSetDerpmapURL(h C.int, url *C.char) C.int { + c := getClient(h) + if c == nil { + return C.EBADF + } + c.cl.DERPMapURL = C.GoString(url) + return 0 +} + +//export TailcatClientSetLogFD +func TailcatClientSetLogFD(h, fd C.int) C.int { + c := getClient(h) + if c == nil { + return C.EBADF + } + c.mu.Lock() + defer c.mu.Unlock() + c.logf = logfForFD(fd) + return 0 +} + +//export TailcatClientConnect +func TailcatClientConnect(h C.int, latencyMsOut *C.double) C.int { + c := getClient(h) + if c == nil { + return C.EBADF + } + res, err := c.cl.Ping(context.Background()) + if err != nil { + return c.recErr(err) + } + if latencyMsOut != nil { + *latencyMsOut = C.double(res.Latency.Seconds() * 1e3) + } + return 0 +} + +// dialTimeout bounds a TailcatClientDial tunnel establishment plus +// TCP connect. The meow handshake inside it has its own 10s cap. +const dialTimeout = 30 * time.Second + +//export TailcatClientDial +func TailcatClientDial(h C.int, port C.int, connOut *C.int) C.int { + c := getClient(h) + if c == nil { + return C.EBADF + } + if port < 0 || port > 65535 { + return c.recErr(fmt.Errorf("libtailcat: invalid port %d", port)) + } + + conn, connFD, err := newConn() + if err != nil { + return c.recErr(err) + } + // The fd is valid (and safe to epoll or write to) immediately; + // writes buffer in the socketpair until the dial completes. Set + // the out param before the goroutine can emit events naming it. + *connOut = connFD + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), dialTimeout) + defer cancel() + netConn, err := c.cl.DialTCPPort(ctx, uint16(port)) + if err != nil { + // Closing the Go side gives the C side EOF on read. + conn.cleanup() + c.ev.enqueue(map[string]any{ + "type": "dial-error", + "conn": int(connFD), + "err": err.Error(), + }) + return + } + conn.start(netConn) + c.ev.enqueue(map[string]any{ + "type": "dial-ok", + "conn": int(connFD), + }) + }() + return 0 +} + +//export TailcatClientClose +func TailcatClientClose(h C.int) C.int { + v := deleteHandle(h) + c, ok := v.(*tcClient) + if !ok { + return C.EBADF + } + close(c.done) + c.ev.close() + if err := c.cl.Close(); err != nil { + return -1 + } + return 0 +} + +//export TailcatEventsFD +func TailcatEventsFD(h C.int) C.int { + st := getState(h) + if st == nil { + // Not EBADF: that's a plausible fd number. + return -1 + } + return st.ev.cFD +} + +//export TailcatEventNext +func TailcatEventNext(h C.int, buf *C.char, buflen C.size_t) C.int { + st := getState(h) + if st == nil { + copyOut("", buf, buflen) + return C.EBADF + } + st.ev.mu.Lock() + defer st.ev.mu.Unlock() + if len(st.ev.queue) == 0 { + copyOut("", buf, buflen) + return C.EAGAIN + } + // Pop only if it fits, so a caller getting ERANGE can retry with + // a bigger buffer without losing the event. + if ret := copyOut(st.ev.queue[0], buf, buflen); ret != 0 { + return ret + } + st.ev.queue = st.ev.queue[1:] + return 0 +} + +//export TailcatErrmsg +func TailcatErrmsg(h C.int, buf *C.char, buflen C.size_t) C.int { + st := getState(h) + if st == nil { + copyOut("", buf, buflen) + return C.EBADF + } + st.errMu.Lock() + defer st.errMu.Unlock() + return copyOut(st.lastErr, buf, buflen) +} + +// conns tracks all live connection socketpairs, for leak detection in +// tests and to make teardown idempotent. +var conns struct { + mu sync.Mutex + m map[*conn]bool +} + +// conn shuttles bytes between a tailcat net.Conn and the socketpair +// whose far end was handed to C. +type conn struct { + r *os.File // Go side of the socketpair + + mu sync.Mutex + c net.Conn // nil until start +} + +// newConn allocates the socketpair for a connection and registers it. +// The returned fd is the C side. Pumping starts when start is called +// with the tailcat connection; until then, C-side writes accumulate +// in the socketpair buffer. +func newConn() (*conn, C.int, error) { + fds, err := syscall.Socketpair(syscall.AF_LOCAL, syscall.SOCK_STREAM, 0) + if err != nil { + return nil, 0, err + } + c := &conn{r: os.NewFile(uintptr(fds[1]), "socketpair-r")} + conns.mu.Lock() + if conns.m == nil { + conns.m = map[*conn]bool{} + } + conns.m[c] = true + conns.mu.Unlock() + return c, C.int(fds[0]), nil +} + +// start begins copying between netConn and the socketpair, in both +// directions. When one direction finishes, its half-close is +// propagated (shutdown on the socketpair, CloseRead/CloseWrite on the +// tailcat side) but the other direction keeps flowing, so netcat-style +// protocols that FIN one way and then read the response work. Full +// teardown happens only once both directions are done. +func (c *conn) start(netConn net.Conn) { + c.mu.Lock() + c.c = netConn + c.mu.Unlock() + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + var b [1 << 16]byte + io.CopyBuffer(c.r, netConn, b[:]) + syscall.Shutdown(int(c.r.Fd()), syscall.SHUT_WR) + if cr, ok := netConn.(interface{ CloseRead() error }); ok { + cr.CloseRead() + } + }() + go func() { + defer wg.Done() + var b [1 << 16]byte + io.CopyBuffer(netConn, c.r, b[:]) + syscall.Shutdown(int(c.r.Fd()), syscall.SHUT_RD) + if cw, ok := netConn.(interface{ CloseWrite() error }); ok { + cw.CloseWrite() + } + }() + go func() { + wg.Wait() + c.cleanup() + }() +} + +// cleanup closes the Go side of the conn. It is safe to call multiple +// times; only the call that removes the conn from the registry closes +// anything. +func (c *conn) cleanup() { + conns.mu.Lock() + registered := conns.m[c] + delete(conns.m, c) + conns.mu.Unlock() + if !registered { + return + } + c.r.Close() + c.mu.Lock() + netConn := c.c + c.mu.Unlock() + if netConn != nil { + netConn.Close() + } +} diff --git a/libtailcat/tailcat.c b/libtailcat/tailcat.c new file mode 100644 index 000000000..1282789c9 --- /dev/null +++ b/libtailcat/tailcat.c @@ -0,0 +1,106 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +#include "tailcat.h" + +// Functions exported by Go (see libtailcat.go). +extern int TailcatKeypairNew(char* buf, size_t buflen); +extern int TailcatPubkey(char* privkey, char* buf, size_t buflen); +extern int TailcatServerNew(char* privkey); +extern int TailcatServerSetDerpmapURL(int s, char* url); +extern int TailcatServerSetRegionID(int s, int regionID); +extern int TailcatServerSetLogFD(int s, int fd); +extern int TailcatServerStart(int s); +extern int TailcatServerConnblob(int s, char* buf, size_t buflen); +extern int TailcatServerListen(int s, int port, int* listenerOut); +extern int TailcatAccept(int listenerFD, int* connOut); +extern int TailcatServerClose(int s); +extern int TailcatClientNew(char* connblob, char* privkey); +extern int TailcatClientSetDerpmapURL(int c, char* url); +extern int TailcatClientSetLogFD(int c, int fd); +extern int TailcatClientConnect(int c, double* latencyMsOut); +extern int TailcatClientDial(int c, int port, int* connOut); +extern int TailcatClientClose(int c); +extern int TailcatEventsFD(int handle); +extern int TailcatEventNext(int handle, char* buf, size_t buflen); +extern int TailcatErrmsg(int handle, char* buf, size_t buflen); + +int tailcat_keypair_new(char* buf, size_t buflen) { + return TailcatKeypairNew(buf, buflen); +} + +int tailcat_pubkey(const char* privkey, char* buf, size_t buflen) { + return TailcatPubkey((char*)privkey, buf, buflen); +} + +tailcat_server tailcat_server_new(const char* privkey) { + return TailcatServerNew((char*)privkey); +} + +int tailcat_server_set_derpmap_url(tailcat_server s, const char* url) { + return TailcatServerSetDerpmapURL(s, (char*)url); +} + +int tailcat_server_set_region_id(tailcat_server s, int region_id) { + return TailcatServerSetRegionID(s, region_id); +} + +int tailcat_server_set_logfd(tailcat_server s, int fd) { + return TailcatServerSetLogFD(s, fd); +} + +int tailcat_server_start(tailcat_server s) { + return TailcatServerStart(s); +} + +int tailcat_server_connblob(tailcat_server s, char* buf, size_t buflen) { + return TailcatServerConnblob(s, buf, buflen); +} + +int tailcat_server_listen(tailcat_server s, int port, tailcat_listener* listener_out) { + return TailcatServerListen(s, port, (int*)listener_out); +} + +int tailcat_accept(tailcat_listener l, tailcat_conn* conn_out) { + return TailcatAccept(l, (int*)conn_out); +} + +int tailcat_server_close(tailcat_server s) { + return TailcatServerClose(s); +} + +tailcat_client tailcat_client_new(const char* connblob, const char* privkey) { + return TailcatClientNew((char*)connblob, (char*)privkey); +} + +int tailcat_client_set_derpmap_url(tailcat_client c, const char* url) { + return TailcatClientSetDerpmapURL(c, (char*)url); +} + +int tailcat_client_set_logfd(tailcat_client c, int fd) { + return TailcatClientSetLogFD(c, fd); +} + +int tailcat_client_connect(tailcat_client c, double* latency_ms_out) { + return TailcatClientConnect(c, latency_ms_out); +} + +int tailcat_client_dial(tailcat_client c, int port, tailcat_conn* conn_out) { + return TailcatClientDial(c, port, (int*)conn_out); +} + +int tailcat_client_close(tailcat_client c) { + return TailcatClientClose(c); +} + +int tailcat_events_fd(int handle) { + return TailcatEventsFD(handle); +} + +int tailcat_event_next(int handle, char* buf, size_t buflen) { + return TailcatEventNext(handle, buf, buflen); +} + +int tailcat_errmsg(int handle, char* buf, size_t buflen) { + return TailcatErrmsg(handle, buf, buflen); +} diff --git a/libtailcat/tailcat.h b/libtailcat/tailcat.h new file mode 100644 index 000000000..3ff1f2d6b --- /dev/null +++ b/libtailcat/tailcat.h @@ -0,0 +1,207 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Tailcat C library. +// +// Use this library to compile tailcat into your program: a +// control-plane-free encrypted pipe (WireGuard + NAT traversal, +// bootstrapped over a DERP relay) between a server and its clients, +// with no Tailscale account required. See the tailcat Go package +// documentation for the underlying concepts (ConnBlob, DERP regions, +// the meow handshake). +// +// Concurrency: all functions are safe to call from any thread. +// +// Connections and listeners are file descriptors (one end of an +// AF_UNIX socketpair; the Go side of the library shuttles bytes +// between them and the encrypted tunnel), so they integrate directly +// with poll/epoll/kqueue event loops. Writing to a connection whose +// peer is gone raises SIGPIPE as usual; callers should ignore SIGPIPE +// or send with MSG_NOSIGNAL. +// +// Errors: unless documented otherwise, functions return 0 on success, +// EBADF if the handle is invalid, ERANGE if an output buffer is too +// small (the buffer is still NUL-terminated), or -1 for other errors +// whose details tailcat_errmsg returns. + +#include + +#ifndef TAILCAT_H +#define TAILCAT_H + +#ifdef __cplusplus +extern "C" { +#endif + +// tailcat_server is a handle onto a tailcat server: the side that +// publishes a connection blob and accepts clients. +typedef int tailcat_server; + +// tailcat_client is a handle onto a tailcat client: the side that +// connects to a server identified by a connection blob. +typedef int tailcat_client; + +// A tailcat_conn is a connection over the tunnel. It is a socketpair +// end on which you can use read(2), write(2), shutdown(2), and +// close(2). Closing it releases the underlying tunnel connection. +typedef int tailcat_conn; + +// A tailcat_listener accepts connections to one TCP port of a server. +// It is a socketpair end that becomes readable when a connection is +// ready to accept (so it can be polled), and connection fds arrive on +// it via SCM_RIGHTS. Use tailcat_accept, and close(2) it when done. +typedef int tailcat_listener; + +// tailcat_keypair_new generates a new node private key and writes its +// text form ("privkey:...") to buf. Store it to give a server (or +// client) a stable identity across runs; a server's identity +// determines its connection blob. +// +// Returns 0, ERANGE, or -1 on internal error. +extern int tailcat_keypair_new(char* buf, size_t buflen); + +// tailcat_pubkey writes the public key ("nodekey:...") of the given +// private key ("privkey:...") to buf. Servers use clients' public +// keys for access control (not yet exposed in this API). +// +// Returns 0, ERANGE, or -1 if privkey doesn't parse. +extern int tailcat_pubkey(const char* privkey, char* buf, size_t buflen); + +// tailcat_server_new creates a server object with the given private +// key ("privkey:..."), or a fresh ephemeral key if privkey is NULL or +// empty. No network activity happens until tailcat_server_start. +// +// Returns the new handle, or 0 on error (bad key or out of fds). +extern tailcat_server tailcat_server_new(const char* privkey); + +// tailcat_server_set_derpmap_url sets the URL of the JSON DERP map +// used to resolve or auto-select the server's relay region. If unset, +// the default tailcat DERP map URL is used. +// +// Must be called before tailcat_server_start. +extern int tailcat_server_set_derpmap_url(tailcat_server s, const char* url); + +// tailcat_server_set_region_id sets the DERP region the server +// listens on. The default of -1 auto-selects the lowest-latency +// region from the DERP map at start. +// +// Must be called before tailcat_server_start. +extern int tailcat_server_set_region_id(tailcat_server s, int region_id); + +// tailcat_server_set_logfd instructs the server to write diagnostic +// logs to fd. An fd of -1 (the default) discards logs. +extern int tailcat_server_set_logfd(tailcat_server s, int fd); + +// tailcat_server_start resolves the DERP region (fetching the DERP +// map over the network if needed), connects to the relay, and begins +// accepting clients. It blocks until the server is up or fails. +extern int tailcat_server_start(tailcat_server s); + +// tailcat_server_connblob writes the server's connection blob +// ("tc..."), the string clients pass to tailcat_client_new, to buf. +// Only valid after tailcat_server_start. +extern int tailcat_server_connblob(tailcat_server s, char* buf, size_t buflen); + +// tailcat_server_listen arranges for connections to the given TCP +// port of the server to be delivered to the new listener written to +// listener_out. It may be called before or after tailcat_server_start. +// Connections to ports with no listener are refused. +// +// Fails if the port already has a listener. +extern int tailcat_server_listen(tailcat_server s, int port, tailcat_listener* listener_out); + +// tailcat_accept accepts a connection from a listener, blocking until +// one is available. Poll the listener for readability first to avoid +// blocking. The new connection is written to conn_out. +extern int tailcat_accept(tailcat_listener l, tailcat_conn* conn_out); + +// tailcat_server_close shuts down the server: its relay connection, +// its listeners, and its event fd. Established connections get EOF as +// their tunnels tear down. +extern int tailcat_server_close(tailcat_server s); + +// tailcat_client_new creates a client that will connect to the server +// identified by connblob ("tc..."), using the given private key +// ("privkey:...") or a fresh ephemeral key if privkey is NULL or +// empty. No network activity happens until the first +// tailcat_client_connect or tailcat_client_dial. +// +// Returns the new handle, or 0 on error (bad blob or key). +extern tailcat_client tailcat_client_new(const char* connblob, const char* privkey); + +// tailcat_client_set_derpmap_url sets the URL of the JSON DERP map +// used to resolve the relay region referenced by the connection blob. +// If unset, the default tailcat DERP map URL is used. +// +// Must be called before the client's first connect or dial. +extern int tailcat_client_set_derpmap_url(tailcat_client c, const char* url); + +// tailcat_client_set_logfd instructs the client to write diagnostic +// logs to fd. An fd of -1 (the default) discards logs. +extern int tailcat_client_set_logfd(tailcat_client c, int fd); + +// tailcat_client_connect establishes the tunnel: it connects to the +// relay and performs the handshake with the server, blocking until +// the server acknowledges the client (internal timeout: 10 seconds). +// +// Calling it is optional (tailcat_client_dial establishes the tunnel +// implicitly) but useful to test connectivity or measure the relay +// round-trip time, written to *latency_ms_out if non-NULL. +extern int tailcat_client_connect(tailcat_client c, double* latency_ms_out); + +// tailcat_client_dial opens a connection to the given TCP port on the +// server. It does not block: the connection is written to conn_out +// immediately and the tunnel is established in the background (30 +// second timeout). Data written meanwhile is buffered (up to the +// socketpair buffer size, typically ~200 kB; writes beyond that block +// or, on a nonblocking fd, fail with EAGAIN). +// +// The outcome arrives as a "dial-ok" or "dial-error" event naming +// this connection (see tailcat_events_fd). On failure the connection +// also reads EOF. Callers that don't watch events can simply treat +// EOF-before-any-data as a failed dial. +extern int tailcat_client_dial(tailcat_client c, int port, tailcat_conn* conn_out); + +// tailcat_client_close shuts down the client, its tunnel, and its +// event fd. Open connections get EOF. +extern int tailcat_client_close(tailcat_client c); + +// tailcat_events_fd returns the event fd of a server or client +// handle, or -1 if the handle is invalid. The fd becomes readable +// when events are pending; it reads EOF once the handle is closed. +// Bytes on it are wakeup hints, not event counts: when it is +// readable, read and discard the available bytes, then call +// tailcat_event_next until it returns EAGAIN. Don't close this fd +// before the handle is closed; after that, the caller may close it. +extern int tailcat_events_fd(int handle); + +// tailcat_event_next pops the next pending event of a server or +// client handle into buf as a JSON object with a "type" field: +// +// {"type":"client-connected","key":"nodekey:..."} (server) +// A new client completed the handshake and can now dial. +// {"type":"connected"} (client) +// The server acknowledged this client; the tunnel is ready. +// {"type":"dial-ok","conn":} (client) +// The dial that returned connection succeeded. +// {"type":"dial-error","conn":,"err":"..."} (client) +// The dial that returned connection failed. +// +// More event types may be added; ignore unknown types. A buffer of +// 512 bytes is sufficient for all current events. +// +// Returns 0, EAGAIN if no event is pending, EBADF, or ERANGE (the +// event is not consumed; retry with a bigger buffer). +extern int tailcat_event_next(int handle, char* buf, size_t buflen); + +// tailcat_errmsg writes the details of the handle's last error to +// buf. After returning, buf is always NUL-terminated. +// +// Returns 0, EBADF, or ERANGE. +extern int tailcat_errmsg(int handle, char* buf, size_t buflen); + +#ifdef __cplusplus +} +#endif + +#endif // TAILCAT_H diff --git a/tailcat.go b/tailcat.go index 159b3d33d..b585266ac 100644 --- a/tailcat.go +++ b/tailcat.go @@ -258,6 +258,13 @@ type Server struct { // destination, not just the server's own address. OnTCPForward func(netip.AddrPort) (handler func(net.Conn)) + // OnClientConnect, if non-nil, is called from its own goroutine + // when a new client completes the meow handshake and is added as + // a WireGuard peer. It is called at most once per client key. + // + // It must be set before calling Start. + OnClientConnect func(key.NodePublic) + // ServedTCPPorts, if non-nil, restricts which TCP ports on the // server's own address the packet filter admits new inbound // connections to. If nil, connections to all ports reach OnTCP, @@ -327,8 +334,13 @@ func NewServer(priv key.NodePrivate, logf logger.Logf, regs ...*tailcfg.DERPRegi // Only reply once the client is fully added as a peer: // "meowed" is the ack that tells the client it can // start dialing. Disallowed clients get no reply. - if lb.onMeow(src, discoPub) { - mc.SendDERPPacketTo(src, regionID, EncodeMeowed()) + allowed, isNew := lb.onMeow(src, discoPub) + if !allowed { + return + } + mc.SendDERPPacketTo(src, regionID, EncodeMeowed()) + if isNew && srv.OnClientConnect != nil { + srv.OnClientConnect(src) } }() return true @@ -435,6 +447,25 @@ func (s *Server) Start() error { return s.lb.Start() } +// WaitDERPConnected blocks until the server has an established +// connection to its DERP relay region, or ctx is done. Start +// initiates that connection but does not wait for it; a meow from a +// client that arrives at the relay before the server is connected is +// lost, so callers that hand out the ConnBlob immediately after Start +// should wait first. +func (s *Server) WaitDERPConnected(ctx context.Context) error { + regionID := s.lb.derpRegionID() + ht := s.lb.sys.HealthTracker.Get() + for ht.GetDERPRegionReceivedTime(regionID).IsZero() { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } + return nil +} + // Close shuts down the server, closing the WireGuard engine and DERP connections. func (s *Server) Close() error { return s.lb.Close() } @@ -885,20 +916,22 @@ func (lb *locoBackend) Start() error { } // onMeow handles a MeowPing from the client with node key src and -// disco key discoPub, adding it as a WireGuard peer. It reports -// whether the client is allowed and configured, meaning a "meowed" -// acknowledgment may be sent. -func (b *locoBackend) onMeow(src key.NodePublic, discoPub key.DiscoPublic) bool { +// disco key discoPub, adding it as a WireGuard peer. The allowed +// result reports whether the client is allowed and configured, +// meaning a "meowed" acknowledgment may be sent. The isNew result +// reports whether this call added the client, as opposed to it +// already being a peer from an earlier meow. +func (b *locoBackend) onMeow(src key.NodePublic, discoPub key.DiscoPublic) (allowed, isNew bool) { b.mu.Lock() defer b.mu.Unlock() b.logf("got meow from %v", src.String()) if b.allowedClients != nil && !b.allowedClients[src] { b.logf("ignoring meow from %v: not in allowedClients", src.String()) - return false + return false, false } if _, ok := b.clients[src]; ok { - return true + return true, false } id := len(b.clients) + 2 // server is ID 1, clients are IDs 2, 3, ... derpRegion := b.derpRegionID() @@ -943,7 +976,7 @@ func (b *locoBackend) onMeow(src key.NodePublic, discoPub key.DiscoPublic) bool // No engine reconfig needed: the WireGuard device learns about the // new peer lazily via the config source installed with // SetPeerConfigFunc when the client's handshake arrives. - return true + return true, true } func (b *locoBackend) Status() *ipnstate.Status { @@ -1132,6 +1165,12 @@ func NewClient(logf logger.Logf, server ConnBlob, priv key.NodePrivate) (*Client // PublicKey returns the client's node public key. func (c *Client) PublicKey() key.NodePublic { return c.lb.pub } +// Connected returns a channel that is closed once the server has +// acknowledged this client via the meow handshake, meaning the +// tunnel is ready for dialing. The handshake happens implicitly on +// the first Dial or [Client.Ping] call. +func (c *Client) Connected() <-chan struct{} { return c.meowWait } + // Close shuts down the client, closing the WireGuard engine and DERP connections. func (c *Client) Close() error { return c.lb.Close() }