Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ jobs:
# long timeout. actions/setup-go's caches keep warm runs fast.
- run: go test -count=1 -timeout 600s ./...
- run: go vet ./...
- name: build libtailcat and run smoke test
shell: bash
run: |
case "$RUNNER_OS" in
Linux) lib=libtailcat.so exe= ;;
macOS) lib=libtailcat.dylib exe= ;;
Windows) lib=libtailcat.dll exe=.exe ;;
esac
mkdir -p build
CGO_ENABLED=1 go build -buildmode=c-shared -o "build/$lib" ./cmd/libtailcat
gcc -Wall -Wextra -Werror -o "build/smoke$exe" cmd/libtailcat/testdata/smoke.c -Icmd/libtailcat "build/$lib"
LD_LIBRARY_PATH=build DYLD_LIBRARY_PATH=build "build/smoke$exe"
# MSVC cannot build the DLL itself, but it can link with it
- name: build and run MSVC libtailcat smoke test
if: runner.os == 'Windows'
shell: cmd
run: |
for /f "usebackq delims=" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -property installationPath`) do set "VSDIR=%%i"
call "%VSDIR%\VC\Auxiliary\Build\vcvars64.bat" || exit /b 1
lib /nologo /def:cmd\libtailcat\libtailcat.def /machine:x64 /out:build\libtailcat.lib || exit /b 1
cl /nologo /W4 /WX /Icmd\libtailcat /Fobuild\ /Febuild\smoke-msvc.exe cmd\libtailcat\testdata\smoke.c /link build\libtailcat.lib || exit /b 1
build\smoke-msvc.exe
tidy:
runs-on: ubuntu-latest
steps:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@
# Go test artifacts.
*.test
*.out

# libtailcat build output (see cmd/libtailcat/README.md).
/build
142 changes: 142 additions & 0 deletions cmd/libtailcat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# libtailcat C API

Build the shared library and generated linker header from the Tailcat module:

```sh
CGO_ENABLED=1 go build -buildmode=c-shared -o libtailcat.so ./cmd/libtailcat
```

Use `.dylib` on macOS and `.dll` on Windows. A C compiler and the Go toolchain
specified by `go.mod` are required. Production builds should use the tags in
`build-tags.txt`. `tailcat.h` is the public ABI definition; the generated
`libtailcat.h` is a build artifact. ABI version 1 is returned by `tc_abi_version`.

## Windows

cgo requires a GCC-compatible compiler, so the DLL is built with MinGW-w64 `gcc`
(WinLibs, MSYS2, or Chocolatey's `mingw` package), which is also Go's default
`CC` on Windows. MSVC cannot build the DLL but can consume it:

```powershell
$env:CGO_ENABLED = "1"
go build -buildmode=c-shared -o build\libtailcat.dll .\cmd\libtailcat
```

The resulting DLL depends only on `kernel32` and the C runtime the toolchain
targets (UCRT for current MinGW-w64 builds). Callers must be able to find it:
place it next to the executable or on `PATH`. Loading it with `LoadLibrary`
or an FFI layer such as ctypes or P/Invoke needs nothing else. MinGW links
against the DLL directly (`gcc smoke.c build\libtailcat.dll`). MSVC needs an
import library, built from the checked-in export list without any MinGW tools:

```bat
lib /def:cmd\libtailcat\libtailcat.def /machine:x64 /out:build\libtailcat.lib
cl /W4 /Icmd\libtailcat smoke.c /link build\libtailcat.lib
```

`libtailcat.def` must list every function in `main.go`; `internal/capi` has a
test that keeps it, `tailcat.h`, and the `//export` directives in sync. Memory
returned by the library is allocated by the DLL's own C runtime, which is why
it must be released with `tc_free` and never with the caller's `free`.
CI builds the DLL with MinGW and links the smoke test with both toolchains.

## Ownership and errors

All handles are opaque 64-bit integers. Zero is invalid as a resource handle;
only token arguments accept it as the `TC_NO_CANCEL` sentinel. A handle is
never reused within a process. Handles are checked for existence and resource type.
`tc_close` retires a handle, interrupts active calls, and releases its resources.
Calling it on a retired handle returns `TC_CLOSED`.

Clients and servers own their connections. Servers also own listeners. Accepted
connections are children of the server: closing a listener does not close its
accepted connections. Closing a client/server closes its complete resource tree.
An active function retains its Go objects until it returns, even during close.

Every fallible function returns a `TC_*` status and optionally writes an allocated
UTF-8 error string through `char **error`. Every returned string, including JSON,
must be released with `tc_free`. Output string/handle pointers must be non-null;
the error pointer may be null. Inputs are borrowed for the duration of the call.
Strings are NUL-terminated UTF-8. Input pointers must identify valid memory.
No function retains caller buffers or returns pointers into Go memory.

`tc_conn_read` and `tc_conn_write` always set their byte count, including on error.
Process those bytes before the error. TCP EOF is `TC_EOF`; a zero-length UDP
packet is `TC_OK` with count zero. UDP writes above 1232 bytes are rejected.
Read/write calls may be partial. One read and one write may proceed concurrently;
same-direction calls are serialized and their queue time counts toward deadlines.

## Tokens and cancellation

All calls are synchronous. If no caller deadline or explicit cancellation is
needed, pass `TC_NO_CANCEL` (zero) as the token argument:

```c
size_t count = 0;
char *error = NULL;
int32_t status = tc_conn_read(connection, TC_NO_CANCEL,
buffer, sizeof(buffer), &count, &error);
/* Process count bytes, then handle status. */
tc_free(error);
```

This allocates no token and requires no cleanup. Closing the connection, listener,
or owning peer still interrupts its calls, and internal protocol timeouts still
apply. `tc_token_cancel(TC_NO_CANCEL, ...)` and
`tc_close(TC_NO_CANCEL, ...)` return `TC_CLOSED`. Take care with zero-initialized
token variables: passing zero now allows a call to wait indefinitely instead
of reporting an invalid handle.

Create a `tc_token` with `tc_token_new(timeout_ns, ...)`. The timeout begins
at creation; `-1` disables the deadline, zero expires immediately. Pass that handle
to blocking functions, then close it. `tc_token_cancel` is nonblocking and may
run on another thread. Closing a token cancels it too. Cancellation of a
pending accept does not close its listener. Cancellation of read/write interrupts
that direction and leaves the connection available for a later I/O call.

`tc_token_new(-1, ...)` still allocates a cancellable token; it is not the same
as `TC_NO_CANCEL`. Async language wrappers must retain real tokens even when
no timeout is configured, so task cancellation can interrupt their worker threads.

A token cancelled just as a handle-producing call succeeds may still return
a handle: the caller owns and must close that result. Do not free buffers before
the original call returns. `tc_conn_readable` is a non-consuming, nonblocking
TCP probe intended for connection-pool expiry; it reports data, EOF, and errors
as readable. It is not an OS file descriptor or a readiness subscription API.

## Configuration

`tc_client_new` accepts JSON with `address` (required), `key`, and `derp_map_url`.
`tc_server_new` accepts `key`, `preshared_key`, `region`, `region_id`,
`derp_map_url`, `allowed_clients`, and `udp_idle_timeout` (seconds). Unknown
fields and invalid keys are errors. Logging is discarded by default.

Keys use Tailcat/Tailscale text encodings. `tc_key_generate` returns JSON with
`private_key`, `public_key`, and `preshared_key`. Persist both private and pre-shared
keys to preserve server identity across restarts. `region` is a Tailscale
DERPRegion in its JSON encoding, with Go field names such as `RegionID` and
`Nodes`; that encoding is part of ABI version 1. Omitting it uses relay
discovery. The default relay map is Tailcat's public map.

`tc_server_start` is idempotent at the C API layer. Listening also starts a server.
Unknown configuration fields are reported by name; other configuration errors
are generic so that error text never echoes secrets.
Use `TC_TCP` or `TC_UDP` and a numerical port; listen port zero chooses a free port.
`tc_client_dial` connects directly to a service port on the configured peer.

`tc_info` returns JSON specific to the handle: client public key, server address
and public key, listener local address, or connection local/remote addresses.
Server info requires startup. Endpoint strings are in host:port form (IPv6 hosts
are bracketed). `tc_client_ping` writes relay latency to `int32_t *ping_ms` in
whole milliseconds (fractional milliseconds are truncated). `tc_client_disco_ping`
returns JSON with latency in seconds, endpoint, and DERP-region information.
`tc_drain` waits for TCP shutdown
using the supplied token deadline, and is a no-op before startup.

`tc_address_parse` returns public metadata without the pre-shared key.
`tc_address_resolve` embeds relay information in an address. Addresses and generated
private/pre-shared keys are secrets and should not be included in logs.

This shared library embeds a Go runtime. Keep it loaded for the process lifetime;
do not use it after `fork` without `exec`. This ABI exposes network primitives,
not the Tailcat CLI's SSH, file-sharing, or arbitrary forwarding services.
36 changes: 36 additions & 0 deletions cmd/libtailcat/libtailcat.def
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
; Copyright (c) Tailscale Inc & contributors
; SPDX-License-Identifier: BSD-3-Clause
;
; Module definition for libtailcat.dll: every function exported from
; main.go, for building an MSVC import library without MinGW tools:
;
; lib /def:libtailcat.def /machine:x64 /out:libtailcat.lib
;
; internal/capi/exports_test.go keeps this list in sync with main.go
; and tailcat.h.
LIBRARY libtailcat.dll
EXPORTS
tc_abi_version
tc_address_parse
tc_address_resolve
tc_build_info
tc_client_dial
tc_client_disco_ping
tc_client_new
tc_client_ping
tc_close
tc_conn_close_write
tc_conn_read
tc_conn_readable
tc_conn_write
tc_drain
tc_free
tc_info
tc_key_generate
tc_listener_accept
tc_server_allow_client
tc_server_listen
tc_server_new
tc_server_start
tc_token_cancel
tc_token_new
Loading