├── .github └── workflows │ ├── ruby.yml │ ├── sourcepkg.yml │ ├── swift.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── example └── echo_server.c ├── go.mod ├── go.sum ├── python ├── .gitignore ├── CMakeLists.txt ├── Makefile ├── README.md ├── examples │ └── echo.py ├── flake.lock ├── flake.nix ├── pyproject.toml ├── setup.py ├── src │ └── main.cpp └── tailscale │ ├── __init__.py │ └── tsnet.py ├── ruby ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── bin │ └── echo_server ├── ext │ └── libtailscale │ │ └── extconf.rb ├── lib │ ├── tailscale.rb │ └── tailscale │ │ └── version.rb ├── tailscale.gemspec └── test │ ├── tailscale │ └── test_tailscale.rb │ └── test_helper.rb ├── sourcepkg ├── Makefile ├── Makefile.src ├── README.md └── configure ├── swift ├── Examples │ └── TailscaleKitHello │ │ ├── HelloFromTailscale │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── HelloFromTailscale.entitlements │ │ ├── HelloFromTailscaleApp.swift │ │ ├── HelloManager.swift │ │ ├── HelloModel.swift │ │ ├── HelloView.swift │ │ ├── HelloViewModel.swift │ │ ├── Info.plist │ │ ├── Info_iOS.plist │ │ ├── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ │ └── Contents.json │ │ └── TailnetSettings.swift │ │ ├── README.md │ │ └── TailscaleKitHello.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── xcschemes │ │ ├── HelloFromTailscale iOS.xcscheme │ │ └── HelloFromTailscale_iOS_Sim.xcscheme ├── Makefile ├── README.md ├── TailscaleKit.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ ├── TailscaleKit (Simulator).xcscheme │ │ ├── TailscaleKit (iOS).xcscheme │ │ ├── TailscaleKit (macOS).xcscheme │ │ ├── TailscaleKitTestHost.xcscheme │ │ ├── TailscaleKitXCTests.xcscheme │ │ ├── libtailscale (ios).xcscheme │ │ ├── libtailscale (macOS).xcscheme │ │ └── libtstestcontrol.xcscheme ├── TailscaleKit │ ├── IncomingConnection.swift │ ├── Listener.swift │ ├── LocalAPI │ │ ├── GoTime.swift │ │ ├── LocalAPIClient.swift │ │ ├── MessageProcessor.swift │ │ ├── MessageReader.swift │ │ └── Types.swift │ ├── LogSink.swift │ ├── OutgoingConnection.swift │ ├── TailscaleError.swift │ ├── TailscaleKit.docc │ │ └── Tailscale.md │ ├── TailscaleKit.h │ ├── TailscaleNode.swift │ └── URLSession+Tailscale.swift ├── TailscaleKitXCTests │ ├── TailscaleKitTests.swift │ └── Test-Bridging-Header.h └── script │ ├── clangwrap-ios-sim-arm.sh │ ├── clangwrap-ios-sim-x86.sh │ └── clangwrap-ios.sh ├── tailscale.c ├── tailscale.go ├── tailscale.h ├── tailscale_test.go ├── tsnetctest └── tsnetctest.go └── tstestcontrol ├── Makefile ├── tstestcontrol.c ├── tstestcontrol.go └── tstestcontrol.h /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.2.0' 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v3 24 | with: 25 | go-version: "1.21" 26 | 27 | - name: Set up Ruby 28 | uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | bundler-cache: true 32 | working-directory: ruby 33 | - name: Run build & test 34 | run: | 35 | cd ruby 36 | bundle exec rake build test -------------------------------------------------------------------------------- /.github/workflows/sourcepkg.yml: -------------------------------------------------------------------------------- 1 | name: Source Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v3 19 | with: 20 | go-version-file: "go.mod" 21 | 22 | - name: Run build & test 23 | run: | 24 | cd sourcepkg 25 | make check -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: swift 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macos-15 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Set up Go 13 | uses: actions/setup-go@v3 14 | with: 15 | go-version: "1.24" 16 | 17 | - name: Test 18 | run: cd swift && make test 19 | 20 | - name: iOS 21 | run: cd swift && make ios-fat 22 | 23 | - name: macos 24 | run: cd swift && make macos 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Set up Go 13 | uses: actions/setup-go@v3 14 | with: 15 | go-version: "1.21" 16 | 17 | - name: Test 18 | run: go test -v ./... 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | libtailscale.so 2 | libtailscale.a 3 | libtailscale.h 4 | libtailscale.tar* 5 | libtailscale_*.a 6 | libtailscale_*.h 7 | 8 | /tstestcontrol/libtstestcontrol.a 9 | /tstestcontrol/libtstestcontrol.h 10 | 11 | /swift/build 12 | **/xcuserdata/** 13 | 14 | /ruby/tmp/ 15 | /ruby/pkg/ 16 | /ruby/doc/ 17 | /ruby/ext/libtailscale/*.go 18 | /ruby/ext/libtailscale/go.mod 19 | /ruby/ext/libtailscale/go.sum 20 | /ruby/LICENSE 21 | 22 | /sourcepkg/libtailscale 23 | /sourcepkg/libtailscale.tar* 24 | 25 | /vendor/ 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023 Tailscale & AUTHORS. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | 5 | libtailscale.a: 6 | go build -buildmode=c-archive 7 | 8 | libtailscale_ios.a: 9 | GOOS=ios GOARCH=arm64 CGO_ENABLED=1 CC=$(PWD)/swift/script/clangwrap-ios.sh go build -v -ldflags -w -tags ios -o libtailscale_ios.a -buildmode=c-archive 10 | 11 | libtailscale_ios_sim_arm64.a: 12 | GOOS=ios GOARCH=arm64 CGO_ENABLED=1 CC=$(PWD)/swift/script/clangwrap-ios-sim-arm.sh go build -v -ldflags -w -tags ios -o libtailscale_ios_sim_arm64.a -buildmode=c-archive 13 | 14 | libtailscale_ios_sim_x86_64.a: 15 | GOOS=ios GOARCH=amd64 CGO_ENABLED=1 CC=$(PWD)/swift/script/clangwrap-ios-sim-x86.sh go build -v -ldflags -w -tags ios -o libtailscale_ios_sim_x86_64.a -buildmode=c-archive 16 | 17 | .PHONY: c-archive-ios 18 | c-archive-ios: libtailscale_ios.a ## Builds libtailscale_ios.a for iOS (iOS SDK required) 19 | 20 | .PHONY: c-archive-ios-sim 21 | c-archive-ios-sim: libtailscale_ios_sim_arm64.a libtailscale_ios_sim_x86_64.a ## Builds a fat binary for iOS (iOS SDK required) 22 | lipo -create -output libtailscale_ios_sim.a libtailscale_ios_sim_x86_64.a libtailscale_ios_sim_arm64.a 23 | 24 | .PHONY: c-archive 25 | c-archive: libtailscale.a ## Builds libtailscale.a for the target platform 26 | 27 | .PHONY: shared 28 | shared: ## Builds libtailscale.so for the target platform 29 | go build -v -buildmode=c-shared 30 | 31 | .PHONY: clean 32 | clean: ## Clean up build artifacts 33 | rm -f libtailscale*.h 34 | rm -f libtailscale*.a 35 | 36 | .PHONY: help 37 | help: ## Show this help 38 | @echo "\nSpecify a command. The choices are:\n" 39 | @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-12s\033[m %s\n", $$1, $$2}' 40 | @echo "" 41 | 42 | .DEFAULT_GOAL := help 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # libtailscale 2 | 3 | libtailscale is a C library that embeds Tailscale into a process. 4 | 5 | Use this library to compile Tailscale into your program and get 6 | an IP address on a tailnet, entirely from userspace. 7 | 8 | ## Building 9 | 10 | With the latest version of Go, run: 11 | 12 | ``` 13 | go build -buildmode=c-archive 14 | ``` 15 | 16 | or 17 | 18 | ``` 19 | make archive 20 | ``` 21 | 22 | This will produce a `libtailscale.a` file. Link it into your binary, 23 | and use the `tailscale.h` header to reference it. 24 | 25 | It is also possible to build a shared library using 26 | 27 | ``` 28 | go build -buildmode=c-shared 29 | ``` 30 | 31 | or 32 | 33 | ``` 34 | make shared 35 | ``` 36 | 37 | ## Bugs 38 | 39 | Please file any issues about this code or the hosted service on 40 | [the issue tracker](https://github.com/tailscale/tailscale/issues). 41 | 42 | ## License 43 | 44 | BSD 3-Clause for this repository, see LICENSE. -------------------------------------------------------------------------------- /example/echo_server.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // 5 | // echo_server is a simple Tailscale node that echos any text sent to port 1999. 6 | // 7 | // To build and run it: 8 | // 9 | // cd libtailscale 10 | // go build -buildmode=c-archive . 11 | // cd example 12 | // cc echo_server.c ../libtailscale.a 13 | // TS_AUTHKEY= ./a.out 14 | // 15 | // On macOS you may need to add the following flags to your C compiler: 16 | // 17 | // -framework CoreFoundation -framework Security -framework IOKit 18 | // 19 | 20 | #include "../tailscale.h" 21 | #include 22 | #include 23 | 24 | int err(tailscale ts); 25 | 26 | int main(void) { 27 | int ret; 28 | 29 | tailscale ts = tailscale_new(); 30 | if (tailscale_set_ephemeral(ts, 1)) { 31 | return err(ts); 32 | } 33 | if (tailscale_up(ts)) { 34 | return err(ts); 35 | } 36 | tailscale_listener ln; 37 | if (tailscale_listen(ts, "tcp", ":1999", &ln)) { 38 | return err(ts); 39 | } 40 | while (1) { 41 | tailscale_conn conn; 42 | if (tailscale_accept(ln, &conn)) { 43 | return err(ts); 44 | } 45 | char buf[2048]; 46 | while ((ret = read(conn, buf, sizeof(buf))) > 0) { 47 | write(1, buf, ret); 48 | } 49 | close(conn); 50 | } 51 | close(ln); 52 | tailscale_close(ts); 53 | 54 | return 0; 55 | } 56 | 57 | char errmsg[256]; 58 | 59 | int err(tailscale ts) { 60 | tailscale_errmsg(ts, errmsg, sizeof(errmsg)); 61 | printf("echo_server: %s\n", errmsg); 62 | return 1; 63 | } 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale/libtailscale 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require tailscale.com v1.82.5 8 | 9 | require ( 10 | filippo.io/edwards25519 v1.1.0 // indirect 11 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect 12 | github.com/akutz/memconn v0.1.0 // indirect 13 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 14 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 15 | github.com/aws/aws-sdk-go-v2 v1.36.0 // indirect 16 | github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect 17 | github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect 18 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect 19 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 // indirect 20 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 // indirect 21 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 // indirect 28 | github.com/aws/smithy-go v1.22.2 // indirect 29 | github.com/coder/websocket v1.8.12 // indirect 30 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect 31 | github.com/creack/pty v1.1.23 // indirect 32 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect 33 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect 34 | github.com/djherbis/times v1.6.0 // indirect 35 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 36 | github.com/gaissmai/bart v0.18.0 // indirect 37 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect 38 | github.com/go-ole/go-ole v1.3.0 // indirect 39 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 40 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 41 | github.com/google/btree v1.1.2 // indirect 42 | github.com/google/go-cmp v0.6.0 // indirect 43 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect 44 | github.com/google/uuid v1.6.0 // indirect 45 | github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect 46 | github.com/gorilla/securecookie v1.1.2 // indirect 47 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 48 | github.com/illarion/gonotify/v3 v3.0.2 // indirect 49 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect 50 | github.com/jellydator/ttlcache/v3 v3.1.0 // indirect 51 | github.com/jmespath/go-jmespath v0.4.0 // indirect 52 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect 53 | github.com/klauspost/compress v1.17.11 // indirect 54 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect 55 | github.com/kr/fs v0.1.0 // indirect 56 | github.com/mdlayher/genetlink v1.3.2 // indirect 57 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 58 | github.com/mdlayher/sdnotify v1.0.0 // indirect 59 | github.com/mdlayher/socket v0.5.0 // indirect 60 | github.com/miekg/dns v1.1.58 // indirect 61 | github.com/mitchellh/go-ps v1.0.0 // indirect 62 | github.com/pierrec/lz4/v4 v4.1.21 // indirect 63 | github.com/pkg/sftp v1.13.6 // indirect 64 | github.com/prometheus-community/pro-bing v0.4.0 // indirect 65 | github.com/safchain/ethtool v0.3.0 // indirect 66 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect 67 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 68 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 69 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect 70 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect 71 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect 72 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect 73 | github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 // indirect 74 | github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 // indirect 75 | github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e // indirect 76 | github.com/u-root/u-root v0.12.0 // indirect 77 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect 78 | github.com/vishvananda/netns v0.0.4 // indirect 79 | github.com/x448/float16 v0.8.4 // indirect 80 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 81 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 82 | golang.org/x/crypto v0.35.0 // indirect 83 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect 84 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect 85 | golang.org/x/mod v0.23.0 // indirect 86 | golang.org/x/net v0.36.0 // indirect 87 | golang.org/x/sync v0.11.0 // indirect 88 | golang.org/x/sys v0.30.0 // indirect 89 | golang.org/x/term v0.29.0 // indirect 90 | golang.org/x/text v0.22.0 // indirect 91 | golang.org/x/time v0.10.0 // indirect 92 | golang.org/x/tools v0.30.0 // indirect 93 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 94 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 95 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect 96 | honnef.co/go/tools v0.5.1 // indirect 97 | ) 98 | -------------------------------------------------------------------------------- /python/.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | 3 | __pycache__/ 4 | tailscale.egg-info/ 5 | dist/ 6 | build/ 7 | *.whl 8 | 9 | pybind11/ 10 | 11 | libtailscale.a 12 | libtailscale.h 13 | -------------------------------------------------------------------------------- /python/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | cmake_minimum_required(VERSION 3.4...3.18) 5 | project(tailscale) 6 | 7 | add_subdirectory(pybind11) 8 | 9 | link_directories(.) 10 | 11 | pybind11_add_module(_tailscale src/main.cpp) 12 | 13 | target_link_libraries(_tailscale PRIVATE tailscale) 14 | 15 | target_compile_definitions(_tailscale PRIVATE VERSION_INFO=${TAILSCALE_VERSION_INFO}) 16 | 17 | target_include_directories(_tailscale PRIVATE .) 18 | -------------------------------------------------------------------------------- /python/Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | build: 5 | @git clone https://github.com/pybind/pybind11 || true 6 | cd pybind11 && git checkout 3cc7e4258c15a6a19ba5e0b62a220b1a6196d4eb 7 | cd .. && go build -buildmode=c-archive -o python/libtailscale.a github.com/tailscale/libtailscale 8 | pip install . 9 | 10 | wheel: 11 | pip wheel . 12 | 13 | clean: 14 | rm -rf pybind11/ libtailscale.a libtailscale.h dist/ build/ tailscale.egg-info/ 15 | 16 | .PHONY: build wheel clean 17 | -------------------------------------------------------------------------------- /python/README.md: -------------------------------------------------------------------------------- 1 | # tailscale 2 | 3 | The tailscale Python package provides an embedded network interface that can be 4 | used to listen for and dial connections to other [Tailscale](https://tailscale.com) nodes. 5 | 6 | ## Build and Install 7 | 8 | Build Requirements: 9 | - Python 3.9 or greater 10 | - A recent Go compiler in $PATH 11 | - CMake (and a C compiler) 12 | - Git 13 | 14 | Start by creating a virtualenv: 15 | 16 | $ python3 -m venv venv 17 | $ source venv/bin/activate 18 | 19 | Install build dependencies, build the c-archive, and install the Python package in your virtualenv: 20 | 21 | $ make build 22 | 23 | Run example echo server: 24 | 25 | $ python3 examples/echo.py 26 | 27 | Build a distributable wheel: 28 | 29 | $ make wheel 30 | => tailscale-0.0.1-cp310-cp310-linux_x86_64.whl 31 | 32 | ## Usage 33 | 34 | The node will need to be authorized in order to function. Set an auth key via 35 | the `$TS_AUTHKEY` environment variable, with `TSNet.set_authkey`, or watch the log 36 | stream and respond to the printed authorization URL. 37 | 38 | ## Contributing 39 | 40 | Pull requests are welcome on GitHub at https://github.com/tailscale/libtailscale 41 | 42 | Please file any issues about this code or the hosted service on 43 | [the issue tracker](https://github.com/tailscale/tailscale/issues). 44 | -------------------------------------------------------------------------------- /python/examples/echo.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | # TODO(shayne): proper select/poll/epoll + os.set_blocking(conn, False) 4 | import os 5 | import select 6 | from tailscale import TSNet 7 | 8 | def handler(conn): 9 | while True: 10 | r, _, _ = select.select([conn], [], [], 10) 11 | if not conn in r: 12 | os._exit(0) 13 | data = os.read(conn, 2048) 14 | print(data.decode(), end="") 15 | 16 | 17 | def main(): 18 | procs = [] 19 | 20 | ts = TSNet(ephemeral=True) 21 | ts.up() 22 | 23 | ln = ts.listen("tcp", ":1999") 24 | while True: 25 | while procs: 26 | pid, exit_code = os.waitpid(-1, os.WNOHANG) 27 | if pid == 0: 28 | break 29 | procs.remove(pid) 30 | 31 | conn = ln.accept() 32 | pid = os.fork() 33 | if pid == 0: 34 | return handler(conn) 35 | procs.append(pid) 36 | 37 | ln.close() 38 | ts.close() 39 | 40 | 41 | if __name__ == "__main__": 42 | main() 43 | -------------------------------------------------------------------------------- /python/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1676283394, 6 | "narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1677932085, 21 | "narHash": "sha256-+AB4dYllWig8iO6vAiGGYl0NEgmMgGHpy9gzWJ3322g=", 22 | "owner": "NixOS", 23 | "repo": "nixpkgs", 24 | "rev": "3c5319ad3aa51551182ac82ea17ab1c6b0f0df89", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "id": "nixpkgs", 29 | "ref": "nixos-unstable", 30 | "type": "indirect" 31 | } 32 | }, 33 | "root": { 34 | "inputs": { 35 | "flake-utils": "flake-utils", 36 | "nixpkgs": "nixpkgs" 37 | } 38 | } 39 | }, 40 | "root": "root", 41 | "version": 7 42 | } 43 | -------------------------------------------------------------------------------- /python/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "nixpkgs/nixos-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: 8 | let 9 | pkgs = nixpkgs.legacyPackages.${system}; 10 | devEnv = (pkgs.buildFHSUserEnv { 11 | name = "libtailscale-python"; 12 | targetPkgs = pkgs: (with pkgs; [ 13 | cmake 14 | python39 15 | ]); 16 | runScript = "${pkgs.writeShellScriptBin "runScript" ('' 17 | set -e 18 | python3 -m venv .venv 19 | source .venv/bin/activate 20 | exec bash 21 | '')}/bin/runScript"; 22 | }).env; 23 | in { 24 | devShell = devEnv; 25 | } 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /python/pyproject.toml: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | [build-system] 5 | requires = [ 6 | "setuptools>=42", 7 | "wheel", 8 | "cmake>=3.12", 9 | ] 10 | build-backend = "setuptools.build_meta" 11 | 12 | [tool.mypy] 13 | files = "setup.py" 14 | python_version = "3.7" 15 | strict = true 16 | show_error_codes = true 17 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 18 | warn_unreachable = true 19 | 20 | [[tool.mypy.overrides]] 21 | ignore_missing_imports = true 22 | 23 | [tool.pytest.ini_options] 24 | minversion = "6.0" 25 | addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] 26 | xfail_strict = true 27 | filterwarnings = ["error"] 28 | testpaths = ["tests"] 29 | 30 | [tool.cibuildwheel] 31 | test-command = "pytest {project}/tests" 32 | test-extras = ["test"] 33 | test-skip = ["*universal2:arm64"] 34 | # Setuptools bug causes collision between pypy and cpython artifacts 35 | before-build = "rm -rf {project}/build" 36 | 37 | [tool.ruff] 38 | extend-select = [ 39 | "B", # flake8-bugbear 40 | "B904", 41 | "I", # isort 42 | "PGH", # pygrep-hooks 43 | "RUF", # Ruff-specific 44 | "UP", # pyupgrade 45 | ] 46 | extend-ignore = [ 47 | "E501", # Line too long 48 | ] 49 | target-version = "py37" 50 | -------------------------------------------------------------------------------- /python/setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import os 5 | import re 6 | import subprocess 7 | import sys 8 | from pathlib import Path 9 | 10 | from setuptools import Extension, setup 11 | from setuptools.command.build_ext import build_ext 12 | 13 | # Convert distutils Windows platform specifiers to CMake -A arguments 14 | PLAT_TO_CMAKE = { 15 | "win32": "Win32", 16 | "win-amd64": "x64", 17 | "win-arm32": "ARM", 18 | "win-arm64": "ARM64", 19 | } 20 | 21 | # A CMakeExtension needs a sourcedir instead of a file list. 22 | # The name must be the _single_ output extension from the CMake build. 23 | # If you need multiple extensions, see scikit-build. 24 | class CMakeExtension(Extension): 25 | def __init__(self, name: str, sourcedir: str = "") -> None: 26 | super().__init__(name, sources=[]) 27 | self.sourcedir = os.fspath(Path(sourcedir).resolve()) 28 | 29 | 30 | class CMakeBuild(build_ext): 31 | def build_extension(self, ext: CMakeExtension) -> None: 32 | # Must be in this form due to bug in .resolve() only fixed in Python 3.10+ 33 | ext_fullpath = Path.cwd() / self.get_ext_fullpath(ext.name) # type: ignore[no-untyped-call] 34 | extdir = ext_fullpath.parent.resolve() 35 | 36 | # Using this requires trailing slash for auto-detection & inclusion of 37 | # auxiliary "native" libs 38 | 39 | debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug 40 | cfg = "Debug" if debug else "Release" 41 | 42 | # CMake lets you override the generator - we need to check this. 43 | # Can be set with Conda-Build, for example. 44 | cmake_generator = os.environ.get("CMAKE_GENERATOR", "") 45 | 46 | # Set Python_EXECUTABLE instead if you use PYBIND11_FINDPYTHON 47 | # TAILSCALE_VERSION_INFO shows you how to pass a value into the C++ code 48 | # from Python. 49 | cmake_args = [ 50 | f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={extdir}{os.sep}", 51 | f"-DPYTHON_EXECUTABLE={sys.executable}", 52 | f"-DCMAKE_BUILD_TYPE={cfg}", # not used on MSVC, but no harm 53 | ] 54 | build_args = [] 55 | # Adding CMake arguments set as environment variable 56 | # (needed e.g. to build for ARM OSx on conda-forge) 57 | if "CMAKE_ARGS" in os.environ: 58 | cmake_args += [item for item in os.environ["CMAKE_ARGS"].split(" ") if item] 59 | 60 | # In this example, we pass in the version to C++. You might not need to. 61 | cmake_args += [f"-DTAILSCALE_VERSION_INFO={self.distribution.get_version()}"] # type: ignore[attr-defined] 62 | 63 | if self.compiler.compiler_type != "msvc": 64 | # Using Ninja-build since it a) is available as a wheel and b) 65 | # multithreads automatically. MSVC would require all variables be 66 | # exported for Ninja to pick it up, which is a little tricky to do. 67 | # Users can override the generator with CMAKE_GENERATOR in CMake 68 | # 3.15+. 69 | if not cmake_generator or cmake_generator == "Ninja": 70 | try: 71 | import ninja 72 | 73 | ninja_executable_path = Path(ninja.BIN_DIR) / "ninja" 74 | cmake_args += [ 75 | "-GNinja", 76 | f"-DCMAKE_MAKE_PROGRAM:FILEPATH={ninja_executable_path}", 77 | ] 78 | except ImportError: 79 | pass 80 | 81 | else: 82 | # Single config generators are handled "normally" 83 | single_config = any(x in cmake_generator for x in {"NMake", "Ninja"}) 84 | 85 | # CMake allows an arch-in-generator style for backward compatibility 86 | contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"}) 87 | 88 | # Specify the arch if using MSVC generator, but only if it doesn't 89 | # contain a backward-compatibility arch spec already in the 90 | # generator name. 91 | if not single_config and not contains_arch: 92 | cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]] 93 | 94 | # Multi-config generators have a different way to specify configs 95 | if not single_config: 96 | cmake_args += [ 97 | f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{cfg.upper()}={extdir}" 98 | ] 99 | build_args += ["--config", cfg] 100 | 101 | if sys.platform.startswith("darwin"): 102 | # Cross-compile support for macOS - respect ARCHFLAGS if set 103 | archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", "")) 104 | if archs: 105 | cmake_args += ["-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))] 106 | 107 | # Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level 108 | # across all generators. 109 | if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ: 110 | # self.parallel is a Python 3 only way to set parallel jobs by hand 111 | # using -j in the build_ext call, not supported by pip or PyPA-build. 112 | if hasattr(self, "parallel") and self.parallel: 113 | # CMake 3.12+ only. 114 | build_args += [f"-j{self.parallel}"] 115 | 116 | build_temp = Path(self.build_temp) / ext.name 117 | if not build_temp.exists(): 118 | build_temp.mkdir(parents=True) 119 | 120 | subprocess.run( 121 | ["cmake", ext.sourcedir, *cmake_args], cwd=build_temp, check=True 122 | ) 123 | subprocess.run( 124 | ["cmake", "--build", ".", *build_args], cwd=build_temp, check=True 125 | ) 126 | 127 | 128 | setup( 129 | name="tailscale", 130 | version="0.0.1", 131 | author="Tailscale Inc & AUTHORS", 132 | author_email="support@tailscale.com", 133 | description="Embedded Tailscale", 134 | long_description="", 135 | ext_modules=[CMakeExtension("tailscale._tailscale")], 136 | packages=["tailscale"], 137 | cmdclass={"build_ext": CMakeBuild}, 138 | zip_safe=False, 139 | extras_require={"test": ["pytest>=6.0"]}, 140 | python_requires=">=3.7", 141 | ) 142 | -------------------------------------------------------------------------------- /python/src/main.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | #include 5 | #include "libtailscale.h" 6 | 7 | #define STRINGIFY(x) #x 8 | #define MACRO_STRINGIFY(x) STRINGIFY(x) 9 | 10 | namespace py = pybind11; 11 | 12 | PYBIND11_MODULE(_tailscale, m) { 13 | m.doc() = R"pbdoc( 14 | Embedded Tailscale 15 | ----------------------- 16 | 17 | .. currentmodule:: _tailscale 18 | 19 | .. autosummary:: 20 | :toctree: _generate 21 | )pbdoc"; 22 | 23 | m.def("new", &TsnetNewServer, R"pbdoc( 24 | Create a new tsnet server 25 | )pbdoc"); 26 | 27 | m.def("start", &TsnetStart, R"pbdoc( 28 | Starts a tsnet server 29 | )pbdoc"); 30 | 31 | m.def("up", &TsnetUp, R"pbdoc( 32 | Brings the given tsnet server up 33 | )pbdoc"); 34 | 35 | m.def("close", &TsnetClose, R"pbdoc( 36 | Closes a given tsnet server 37 | )pbdoc"); 38 | 39 | m.def("err_msg", &TsnetErrmsg, R"pbdoc( 40 | 41 | )pbdoc"); 42 | 43 | m.def("listen", [](int sd, char* network, char* addr) { int listenerOut; int rv = TsnetListen(sd, network, addr, &listenerOut); return std::make_tuple(listenerOut, rv); }, R"pbdoc( 44 | Listen on a given protocol and port 45 | )pbdoc"); 46 | 47 | m.def("accept", [](int ld) { int connOut; int rv = TsnetAccept(ld, &connOut); return std::make_tuple(connOut, rv);}, R"pbdoc( 48 | Accept a given listener and connection 49 | )pbdoc"); 50 | 51 | m.def("dial", &TsnetDial, R"pbdoc( 52 | 53 | )pbdoc"); 54 | 55 | m.def("set_dir", &TsnetSetDir, R"pbdoc( 56 | 57 | )pbdoc"); 58 | 59 | m.def("set_hostname", &TsnetSetHostname, R"pbdoc( 60 | 61 | )pbdoc"); 62 | 63 | m.def("set_authkey", &TsnetSetAuthKey, R"pbdoc( 64 | 65 | )pbdoc"); 66 | 67 | m.def("set_control_url", &TsnetSetControlURL, R"pbdoc( 68 | 69 | )pbdoc"); 70 | 71 | m.def("set_ephemeral", &TsnetSetEphemeral, R"pbdoc( 72 | Set the given tsnet server to be an ephemeral node. 73 | )pbdoc"); 74 | 75 | m.def("set_log_fd", &TsnetSetLogFD, R"pbdoc( 76 | 77 | )pbdoc"); 78 | 79 | m.def("loopback", &TsnetLoopback, R"pbdoc( 80 | 81 | )pbdoc"); 82 | 83 | #ifdef VERSION_INFO 84 | m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); 85 | #else 86 | m.attr("__version__") = "dev"; 87 | #endif 88 | } 89 | -------------------------------------------------------------------------------- /python/tailscale/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | from .tsnet import TSNet, TSNetException 5 | -------------------------------------------------------------------------------- /python/tailscale/tsnet.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import io 5 | 6 | from . import _tailscale 7 | 8 | class TSNetException(Exception): pass 9 | 10 | 11 | class TSNet: 12 | def __init__(self, ephemeral=False): 13 | self.ts = _tailscale.new() 14 | if ephemeral and _tailscale.set_ephemeral(self.ts, 1): 15 | raise TSNetException("Error setting ephemeral") 16 | 17 | def up(self): 18 | if _tailscale.up(self.ts): 19 | raise TSNetException("Error coming up") 20 | 21 | def listen(self, proto, addr): 22 | ln, err = _tailscale.listen(self.ts, proto, addr) 23 | if err: 24 | raise TSNetException("Error listening: %s on %s" % (proto, addr)) 25 | return TSNetListener(ln) 26 | 27 | def close(self): 28 | if _tailscale.close(self.ts): 29 | raise TSNetException("Failed to close") 30 | 31 | 32 | class TSNetListener: 33 | def __init__(self, ln): 34 | self.ln = ln 35 | 36 | def accept(self): 37 | fd, err = _tailscale.accept(self.ln) 38 | if err: 39 | raise TSNetException("Failed to accept conn") 40 | return fd 41 | 42 | def close(self): 43 | if _tailscale.close_listener(self.ln): 44 | raise TSNetException("Failed to close") 45 | -------------------------------------------------------------------------------- /ruby/Gemfile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | # frozen_string_literal: true 4 | 5 | source "https://rubygems.org" 6 | 7 | # Specify your gem's dependencies in tailscale-ruby.gemspec 8 | gemspec 9 | 10 | gem "rake", "~> 13.0" 11 | gem "rake-compiler", "~> 1.2.1" 12 | gem "minitest", "~> 5.0" -------------------------------------------------------------------------------- /ruby/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | tailscale (0.1.0) 5 | ffi (~> 1.15.5) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ffi (1.15.5) 11 | minitest (5.16.3) 12 | rake (13.0.6) 13 | rake-compiler (1.2.1) 14 | rake 15 | 16 | PLATFORMS 17 | x86_64-linux-gnu 18 | 19 | DEPENDENCIES 20 | minitest (~> 5.0) 21 | rake (~> 13.0) 22 | rake-compiler (~> 1.2.1) 23 | tailscale! 24 | 25 | BUNDLED WITH 26 | 2.4.1 27 | -------------------------------------------------------------------------------- /ruby/README.md: -------------------------------------------------------------------------------- 1 | # tailscale 2 | 3 | The tailscale gem provides an embedded network interface that can be used to 4 | listen for and dial connections to other [Tailscale](https://tailscale.com) 5 | nodes. 6 | 7 | ## Installation 8 | 9 | Source installations will require a recent Go compiler in $PATH in order to build. 10 | 11 | Install the gem and add to the application's Gemfile by executing: 12 | 13 | $ bundle add tailscale 14 | 15 | If bundler is not being used to manage dependencies, install the gem by executing: 16 | 17 | $ gem install ailscale 18 | 19 | ## Usage 20 | 21 | The node will need to be authorized in order to function. Set an auth key with 22 | `set_auth_key`, or watch the libtailscale log stream and respond to the printed 23 | authorization URL. You can also set the `$TS_AUTHKEY` environment variable. 24 | 25 | ```ruby 26 | require 'tailscale' 27 | t = Tailscale.new 28 | t.up 29 | l = t.listen "tcp", ":1999" 30 | while c = l.accept 31 | c.write "hello world" 32 | c.close 33 | end 34 | ``` 35 | 36 | ## Development 37 | 38 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 39 | 40 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 41 | 42 | ## Contributing 43 | 44 | Pull requests are welcome on GitHub at https://github.com/tailscale/libtailscale 45 | 46 | Please file any issues about this code or the hosted service on 47 | [the issue tracker](https://github.com/tailscale/tailscale/issues). 48 | -------------------------------------------------------------------------------- /ruby/Rakefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | # frozen_string_literal: true 4 | 5 | require "bundler/gem_tasks" 6 | require "rake/testtask" 7 | require "rake/extensiontask" 8 | 9 | go_sources = %w[tailscale.go go.mod go.sum] 10 | go_sources.map do |f| 11 | to = "ext/libtailscale/#{f}" 12 | from = "../#{f}" 13 | file to => from do 14 | cp from, to 15 | end 16 | task copy: to 17 | task :clobber do 18 | rm_f to 19 | end 20 | end 21 | file "LICENSE" => "../LICENSE" do 22 | cp "../LICENSE", "LICENSE" 23 | end 24 | task :clobber do 25 | rm_f "LICENSE" 26 | end 27 | task copy: "LICENSE" 28 | task build: :copy 29 | 30 | # XXX: Rake::ExtensionTask seems to ignore prerequisites. 31 | # Rake::ExtensionTask.new "libtailscale" do |ext| 32 | # ext.source_pattern = "*.{go,mod,sum}" 33 | # end 34 | # task "compile:libtailscale" => :copy 35 | libname = "lib/libtailscale.#{RbConfig::CONFIG['DLEXT']}" 36 | task libname => :copy do |t| 37 | sh "go build -buildmode=c-shared -o #{t.name} github.com/tailscale/libtailscale" 38 | end 39 | desc "Build the C extension using local sources" 40 | task compile: libname 41 | task :clobber do 42 | rm_f libname 43 | rm_f libname.sub(/\.#{RbConfig::CONFIG['DLEXT']}$/, ".h") 44 | end 45 | 46 | Rake::TestTask.new(:test) do |t| 47 | t.libs << "test" 48 | t.libs << "lib" 49 | t.test_files = FileList["test/**/test_*.rb"] 50 | end 51 | task test: :compile 52 | 53 | task default: :test -------------------------------------------------------------------------------- /ruby/bin/echo_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # Copyright (c) Tailscale Inc & AUTHORS 3 | # SPDX-License-Identifier: BSD-3-Clause 4 | # frozen_string_literal: true 5 | 6 | require "bundler/setup" 7 | require "tailscale" 8 | 9 | t = Tailscale::new 10 | t.start 11 | 12 | s = t.listen "tcp", ":1997" 13 | while c = s.accept 14 | while got = c.readpartial(2046) 15 | print got 16 | c.write got 17 | end 18 | c.close 19 | end 20 | -------------------------------------------------------------------------------- /ruby/ext/libtailscale/extconf.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | # frozen_string_literal: true 4 | require 'rbconfig' 5 | open("Makefile", "w") do |f| 6 | f.puts "libtailscale.#{RbConfig::CONFIG['DLEXT']}:" 7 | f.puts "\tgo build -C #{File.expand_path(__dir__)} -buildmode=c-shared -o #{Dir.pwd}/$@ ." 8 | 9 | f.puts "install: libtailscale.#{RbConfig::CONFIG['DLEXT']}" 10 | f.puts "\tmkdir -p #{RbConfig::CONFIG['sitelibdir']}" 11 | f.puts "\tcp libtailscale.#{RbConfig::CONFIG['DLEXT']} #{RbConfig::CONFIG['sitelibdir']}/" 12 | end -------------------------------------------------------------------------------- /ruby/lib/tailscale.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | # frozen_string_literal: true 4 | 5 | require 'tailscale/version' 6 | require 'ffi' 7 | require 'rbconfig' 8 | 9 | # Tailscale provides an embedded tailscale network interface for ruby programs. 10 | class Tailscale 11 | 12 | # Libtailscale is a FFI wrapper around the libtailscale C library. 13 | module Libtailscale 14 | extend FFI::Library 15 | 16 | # In development or in precompiled gems the library is in the lib 17 | # directory, and when installed by rubygems it's in the ruby site lib 18 | # directory. 19 | [__dir__, RbConfig::CONFIG['sitelibdir']].find do |dir| 20 | lib = File.expand_path("libtailscale.#{RbConfig::CONFIG["DLEXT"]}", dir) 21 | if File.exist?(lib) 22 | ffi_lib lib 23 | true 24 | end 25 | end 26 | 27 | attach_function :TsnetNewServer, [], :int 28 | attach_function :TsnetStart, [:int], :int 29 | attach_function :TsnetUp, [:int], :int, blocking: true 30 | attach_function :TsnetClose, [:int], :int 31 | attach_function :TsnetSetDir, [:int, :string], :int 32 | attach_function :TsnetSetHostname, [:int, :string], :int 33 | attach_function :TsnetSetAuthKey, [:int, :string], :int 34 | attach_function :TsnetSetControlURL, [:int, :string], :int 35 | attach_function :TsnetSetEphemeral, [:int, :int], :int 36 | attach_function :TsnetSetLogFD, [:int, :int], :int 37 | attach_function :TsnetDial, [:int, :string, :string, :pointer], :int, blocking: true 38 | attach_function :TsnetListen, [:int, :string, :string, :pointer], :int 39 | attach_function :close, [:int], :int 40 | attach_function :tailscale_accept, [:int, :pointer], :int, blocking: true 41 | attach_function :TsnetErrmsg, [:int, :pointer, :size_t], :int 42 | attach_function :TsnetLoopback, [:int, :pointer, :size_t, :pointer, :pointer], :int 43 | end 44 | 45 | class ClosedError < StandardError 46 | def initialize 47 | super "tailscale error: the server is closed" 48 | end 49 | end 50 | 51 | class Error < StandardError 52 | attr_reader :code 53 | 54 | def initialize(msg, code = -1) 55 | @code = code 56 | super msg 57 | end 58 | 59 | def self.check(ts, code) 60 | return if code == 0 61 | 62 | if code == -1 63 | msg = ts.errmsg 64 | else 65 | msg = "tailscale error: code: #{code}" 66 | end 67 | raise Error.new(msg, code) 68 | end 69 | end 70 | 71 | # A listening socket on the tailscale network. 72 | class Listener 73 | # Create a new listener, user code should not call this directly, 74 | # instead use +Tailscale#listen+. 75 | def initialize(ts, listener) 76 | @ts = ts 77 | @listener = listener 78 | end 79 | 80 | # Accept a new connection. This method blocks until a new connection is 81 | # recieved. An +IO+ object is returned which can be used to read and 82 | # write. 83 | def accept 84 | @ts.assert_open 85 | conn = FFI::MemoryPointer.new(:int) 86 | Error.check @ts, Libtailscale::TsnetAccept(@listener, conn) 87 | IO::new conn.read_int 88 | end 89 | 90 | # Close the listener. 91 | def close 92 | @ts.assert_open 93 | Error.check @ts, Libtailscale::close(@listener) 94 | end 95 | end 96 | 97 | # LocalAPIClient provides a Net::HTTP-alike API that can be used to make 98 | # authenticated requests to the local tailscale API. For higher level use, 99 | # +LocalAPI+ may be more convenient. 100 | class LocalAPIClient 101 | # address is the host:port address of the nodes LocalAPI server. 102 | attr_reader :address 103 | # credential is the basic-auth password used to authenticate requests. 104 | attr_reader :credential 105 | 106 | def initialize(addr, cred) 107 | @address = addr 108 | @credential = cred 109 | @basic = Base64.strict_encode64(":#{cred}") 110 | host, _, port = addr.rpartition(":") 111 | @http = Net::HTTP.new(host, port) 112 | end 113 | 114 | def head(path, initheader = nil, &block) 115 | request Net::HTTP::Head.new(path, initheader), &block 116 | end 117 | 118 | def get(path, initheader = nil, &block) 119 | request Net::HTTP::Get.new(path, initheader), &block 120 | end 121 | 122 | def post(path, body = nil, initheader = nil, &block) 123 | request Net::HTTP::Post.new(path, initheader), body, &block 124 | end 125 | 126 | def put(path, body = nil, initheader = nil, &block) 127 | request Net::HTTP::Put.new(path, initheader), body, &block 128 | end 129 | 130 | def patch(path, body = nil, initheader = nil, &block) 131 | request Net::HTTP::Patch.new(path, initheader), body, &block 132 | end 133 | 134 | def delete(path, initheader = nil, &block) 135 | request Net::HTTP::Delete.new(path, initheader), &block 136 | end 137 | 138 | def request(req, body = nil, &block) 139 | req["Host"] = @address 140 | req["Authorization"] = "Basic #{@basic}" 141 | req["Sec-Tailscale"] = "localapi" 142 | @http.request(req, body, &block) 143 | end 144 | end 145 | 146 | # LocalAPI provides a convenient interface for interacting with a LocalAPI given a 147 | # LocalAPIClient to make requests with. 148 | class LocalAPI 149 | 150 | def initialize(client) 151 | @client = client 152 | end 153 | 154 | # status returns the status of the local tailscale node. 155 | def status 156 | @client.get("/localapi/v0/status") do |r| 157 | return JSON.parse(r.body) 158 | end 159 | end 160 | 161 | end 162 | 163 | # Create a new tailscale server. 164 | # 165 | # The server is not started, and no network traffic will occur until start 166 | # is called or network operations are used (such as dial or listen). 167 | def initialize 168 | @t = Libtailscale::TsnetNewServer() 169 | raise Error.new("tailscale error: failed to initialize", @t) if @t < 0 170 | end 171 | 172 | # Start the tailscale server asynchronously. 173 | def start 174 | Error.check self, Libtailscale::TsnetStart(@t) 175 | end 176 | 177 | # Bring the tailscale server up and wait for it to be usable. This method 178 | # blocks until the node is fully authorized. 179 | def up 180 | Error.check self, Libtailscale::TsnetUp(@t) 181 | end 182 | 183 | # Close the tailscale server. 184 | def close 185 | Error.check self, Libtailscale::TsnetClose(@t) 186 | @t = -1 187 | end 188 | 189 | # Set the directory to store tailscale state in. 190 | def set_dir(dir) 191 | assert_open 192 | Error.check self, Libtailscale::TsnetSetDir(@t, dir) 193 | end 194 | 195 | # Set the hostname to use for the tailscale node. 196 | def set_hostname(hostname) 197 | assert_open 198 | Error.check self, Libtailscale::TsnetSetHostname(@t, hostname) 199 | end 200 | 201 | # Set the auth key to use for the tailscale node. 202 | def set_auth_key(auth_key) 203 | assert_open 204 | Error.check self, Libtailscale::TsnetSetAuthKey(@t, auth_key) 205 | end 206 | 207 | # Set the control URL the node will connect to. 208 | def set_control_url(control_url) 209 | assert_open 210 | Error.check self, Libtailscale::TsnetSetControlURL(@t, control_url) 211 | end 212 | 213 | # Set whether the node is ephemeral or not. 214 | def set_ephemeral(ephemeral) 215 | assert_open 216 | Error.check self, Libtailscale::TsnetSetEphemeral(@t, ephemeral ? 1 : 0) 217 | end 218 | 219 | # Set the file descriptor to use for logging. The file descriptor must be 220 | # open for writing. e.g. use `IO.sysopen("/dev/null", "w")` to disable 221 | # logging. 222 | def set_log_fd(log_fd) 223 | assert_open 224 | Error.check self, Libtailscale::TsnetSetLogFD(@t, log_fd) 225 | end 226 | 227 | # Dial a network address. +network+ is one of "tcp" or "udp". +addr+ is the 228 | # remote address to connect to, and +local_addr+ is the local address to 229 | # bind to. This method blocks until the connection is established. 230 | def dial(network, addr, local_addr) 231 | assert_open 232 | conn = FFI::MemoryPointer.new(:int) 233 | Error.check self, Libtailscale::TsnetDial(@t, network, addr, conn) 234 | IO::new conn.read_int 235 | end 236 | 237 | # Listen on a network address. +network+ is one of "tcp" or "udp". +addr+ is 238 | # the local address to bind to. 239 | def listen(network, addr) 240 | assert_open 241 | listener = FFI::MemoryPointer.new(:int) 242 | Error.check self, Libtailscale::TsnetListen(@t, network, addr, listener) 243 | Listener.new self, listener.read_int 244 | end 245 | 246 | # Start a listener on a loopback address, and returns the address 247 | # and credentials for using it as LocalAPI or a proxy. 248 | def loopback 249 | assert_open 250 | addrbuf = FFI::MemoryPointer.new(:char, 1024) 251 | proxycredbuf = FFI::MemoryPointer.new(:char, 33) 252 | localcredbuf = FFI::MemoryPointer.new(:char, 33) 253 | Error.check self, Libtailscale::TsnetLoopback(@t, addrbuf, addrbuf.size, proxycredbuf, localcredbuf) 254 | [addrbuf.read_string, proxycredbuf.read_string, localcredbuf.read_string] 255 | end 256 | 257 | # Start the local API and return a LocalAPIClient for interacting with it. 258 | def local_api_client 259 | addr, _, cred = loopback 260 | LocalAPIClient.new(addr, cred) 261 | end 262 | 263 | # Start the local API and return a LocalAPI for interacting with it. 264 | def local_api 265 | LocalAPI.new(local_api_client) 266 | end 267 | 268 | # Get the last detailed error message from the tailscale server. This method 269 | # is typically not needed by user code, as the library will raise an 270 | # +Error+ with the error message. 271 | def errmsg 272 | buf = FFI::MemoryPointer.new(:char, 1024) 273 | r = Libtailscale::TsnetErrmsg(@t, buf, buf.size) 274 | if r != 0 275 | return "tailscale internal error: failed to get error message" 276 | end 277 | buf.read_string 278 | end 279 | 280 | # Check if the tailscale server is open. 281 | def assert_open 282 | raise ClosedError if @t <= 0 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /ruby/lib/tailscale/version.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | # frozen_string_literal: true 5 | 6 | class Tailscale 7 | VERSION = "0.1.0" 8 | end 9 | -------------------------------------------------------------------------------- /ruby/tailscale.gemspec: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | # frozen_string_literal: true 4 | 5 | require_relative "lib/tailscale/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "tailscale" 9 | spec.version = Tailscale::VERSION 10 | spec.authors = ["Tailscale Inc & AUTHORS"] 11 | spec.email = ["support@tailscale.com"] 12 | 13 | spec.summary = "Tailscale in-process connections for Ruby" 14 | spec.description = "Tailscale in-process connections for Ruby" 15 | spec.homepage = "https://www.tailscale.com" 16 | spec.license = "BSD-3-Clause" 17 | spec.required_ruby_version = ">= 2.6.0" 18 | 19 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 20 | 21 | spec.metadata["homepage_uri"] = spec.homepage 22 | spec.metadata["source_code_uri"] = "https://github.com/tailscale/libtailscale/tree/main/ruby" 23 | spec.metadata["bug_tracker_uri"] = "https://github.com/tailscale/tailscale/issues" 24 | 25 | spec.files = Dir.chdir(__dir__) do 26 | `git ls-files -z`.split("\x0").filter { |f| f =~ %r{^lib/|ext/|README.md|LICENSE} } 27 | end 28 | spec.files += ["LICENSE"] 29 | spec.files += Dir["ext/libtailscale/*.{mod,sum,go}"] 30 | 31 | spec.bindir = "exe" 32 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 33 | spec.require_paths = ["lib"] 34 | spec.extensions = ["ext/libtailscale/extconf.rb"] 35 | 36 | spec.add_dependency "ffi", "~> 1.15.5" 37 | end 38 | -------------------------------------------------------------------------------- /ruby/test/tailscale/test_tailscale.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | # frozen_string_literal: true 4 | require 'test_helper' 5 | 6 | class TestTailscale < Minitest::Test 7 | 8 | def test_that_it_has_a_version_number 9 | refute_nil ::Tailscale::VERSION 10 | end 11 | 12 | def test_listen_sorta_works 13 | # TODO: make a more useful test when we can make a client to connect with. 14 | ts = newts 15 | ts.start 16 | s = ts.listen "tcp", ":1999" 17 | s.close 18 | ts.close 19 | end 20 | 21 | def test_dial_sorta_works 22 | # TODO: make a more useful test when we can make a server to connect to. 23 | ts = newts 24 | ts.start 25 | c = ts.dial "udp", "100.100.100.100:53", "" 26 | c.close 27 | ts.close 28 | end 29 | 30 | def newts 31 | t = Tailscale::new 32 | unless ENV['VERBOSE'] 33 | logfd = IO.sysopen("/dev/null", "w+") 34 | t.set_log_fd logfd 35 | end 36 | t 37 | end 38 | end -------------------------------------------------------------------------------- /ruby/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | # frozen_string_literal: true 4 | 5 | require "tailscale" 6 | require "minitest/autorun" 7 | -------------------------------------------------------------------------------- /sourcepkg/Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | # Construct a source package by vendoring all source and packing it up into a 5 | # tarball. 6 | 7 | ifeq ($(shell uname -s),Darwin) 8 | TAR?=gtar 9 | else 10 | TAR?=tar 11 | endif 12 | 13 | all: check 14 | 15 | check: libtailscale.tar.zst 16 | @echo "Checking that the tarball is self-contained..." 17 | test `$(TAR) tf libtailscale.tar.zst | grep -c -v '^libtailscale/'` -eq 0 || (echo "Tarball is not self-contained!" && exit 1) 18 | 19 | @tar xf libtailscale.tar.zst 20 | @echo "Checking that the tarball is usable..." 21 | @cd libtailscale && ./configure && make 22 | 23 | 24 | clean: 25 | rm -rf ./libtailscale.tar.zst ../vendor ./libtailscale 26 | 27 | ../vendor: ../go.mod ../go.sum ../tailscale.go Makefile.src Makefile 28 | go mod vendor 29 | 30 | libtailscale.tar.zst: Makefile.src configure ../vendor ../LICENSE ../tailscale.go ../go.mod ../go.sum 31 | $(TAR) --transform 's#^#libtailscale/#' --transform 's#Makefile.src#Makefile#' -acf $@ Makefile.src configure ../vendor ../LICENSE ../tailscale.go ../go.mod ../go.sum -------------------------------------------------------------------------------- /sourcepkg/Makefile.src: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | prefix?=/usr/local 5 | libdir?=$(prefix)/lib 6 | includedir?=$(prefix)/include 7 | 8 | all: libtailscale.a libtailscale.so libtailscale.pc 9 | 10 | libtailscale.a: 11 | go build -trimpath -buildvcs=false -buildmode=c-archive -o libtailscale.a 12 | 13 | libtailscale.so: 14 | go build -trimpath -buildvcs=false -buildmode=c-shared -o libtailscale.so 15 | 16 | # TODO(raggi): the dylib build currently fails to build for amd64 on macOS on an 17 | # M1, the toolchain reports a build constraints error despite no build 18 | # constraints. 19 | libtailscale.dylib: 20 | GOARCH=amd64 GOOS=darwin go build -trimpath -buildmode=c-shared -o libtailscale.dylib.amd64 . 21 | GOARCH=arm64 GOOS=darwin go build -trimpath -buildmode=c-shared -o libtailscale.dylib.arm64 . 22 | lipo -create -output libtailscale.dylib libtailscale.dylib.amd64 libtailscale.dylib.arm64 23 | 24 | libtailscale.pc: 25 | echo "prefix=/usr/local" > libtailscale.pc 26 | echo "exec_prefix=\$${prefix}" >> libtailscale.pc 27 | echo "libdir=\$${exec_prefix}/lib" >> libtailscale.pc 28 | echo "includedir=\$${prefix}/include" >> libtailscale.pc 29 | echo "" >> libtailscale.pc 30 | echo "Name: libtailscale" >> libtailscale.pc 31 | echo "Description: Tailscale library" >> libtailscale.pc 32 | echo "Version: 0.0.1" >> libtailscale.pc 33 | echo "Libs: -L\$${libdir} -ltailscale" >> libtailscale.pc 34 | echo "Cflags: -I\$${includedir}" >> libtailscale.pc 35 | 36 | install: libtailscale.a libtailscale.so libtailscale.pc 37 | install -d $(DESTDIR)$(libdir) 38 | install -m 644 libtailscale.a $(DESTDIR)$(libdir) 39 | install -m 644 libtailscale.so $(DESTDIR)$(libdir) 40 | install -d $(DESTDIR)$(libdir)/pkgconfig 41 | install -m 644 libtailscale.pc $(DESTDIR)$(libdir)/pkgconfig 42 | install -d $(DESTDIR)$(includedir) 43 | install -m 644 *.h $(DESTDIR)$(includedir) -------------------------------------------------------------------------------- /sourcepkg/README.md: -------------------------------------------------------------------------------- 1 | # libtailscale - Source package 2 | 3 | This directory contains extra code included in the `libtailscale` source 4 | package. The source package can be built using the `Makefile` in the top level 5 | project directory. -------------------------------------------------------------------------------- /sourcepkg/configure: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Copyright (c) Tailscale Inc & AUTHORS 3 | # SPDX-License-Identifier: BSD-3-Clause 4 | 5 | go version > /dev/null 2>&1 || { echo >&2 "A Go compiler is required."; exit 1; } -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloFromTailscale.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloFromTailscaleApp.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import SwiftUI 5 | 6 | @main 7 | struct HelloFromTailscaleApp: App { 8 | let manager = HelloManager.shared 9 | 10 | var body: some Scene { 11 | WindowGroup { 12 | HelloView(dialer: manager, model: manager.model) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloManager.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Foundation 5 | import TailscaleKit 6 | 7 | enum HelloError: Error { 8 | case noNode 9 | } 10 | 11 | typealias MessageSender = @Sendable (String) async -> Void 12 | 13 | struct Logger: TailscaleKit.LogSink { 14 | var logFileHandle: Int32? = STDOUT_FILENO 15 | 16 | func log(_ message: String) { 17 | print("hello: \(message)") 18 | } 19 | } 20 | 21 | protocol Dialer: Actor { 22 | func phoneHome(_ setMessage: @escaping MessageSender) async 23 | } 24 | 25 | actor HelloManager: Dialer { 26 | var node: TailscaleNode? 27 | 28 | static let shared = HelloManager() 29 | 30 | let logger = Logger() 31 | 32 | let config: Configuration 33 | var ready = false 34 | 35 | // The model will be the consumer for our the busWatcher 36 | let model: HelloModel 37 | 38 | var localAPIClient: LocalAPIClient? 39 | var processor: MessageProcessor? 40 | 41 | init() { 42 | let temp = getDocumentDirectoryPath().path() + "tailscale" 43 | self.config = Configuration(hostName: Settings.hostName, 44 | path: temp, 45 | authKey: Settings.authKey, 46 | controlURL: kDefaultControlURL, 47 | ephemeral: true) 48 | 49 | let model = HelloModel(logger: logger) 50 | self.model = model 51 | 52 | Task { 53 | await startTailscale() 54 | } 55 | } 56 | 57 | private func startTailscale() async { 58 | do { 59 | // This sets up a localAPI client attached to the local node. 60 | let node = try setupNode() 61 | try await node.up() 62 | 63 | // Create a localAPIClient instance for our local node 64 | let localAPIClient = LocalAPIClient(localNode: node, logger: logger) 65 | setLocalAPIClient(localAPIClient) 66 | 67 | // This sets up a bus watcher to listen for changes in the netmap. These will be sent to the given consumer, in 68 | // this case, a HelloModel which will keep track of the changes and publish them. 69 | let busEventMask: Ipn.NotifyWatchOpt = [.initialState, .netmap, .rateLimitNetmaps, .noPrivateKeys] 70 | let processor = try await localAPIClient.watchIPNBus(mask: busEventMask , 71 | consumer: model) 72 | setProcessor(processor) 73 | setReady(true) 74 | } catch { 75 | Logger().log("Error setting up Tailscale: \(error)") 76 | setReady(false) 77 | } 78 | } 79 | 80 | func setLocalAPIClient(_ client: TailscaleKit.LocalAPIClient) { 81 | self.localAPIClient = client 82 | } 83 | 84 | func setReady(_ value: Bool) { 85 | self.ready = value 86 | } 87 | 88 | func setProcessor(_ processor: MessageProcessor) { 89 | self.processor = processor 90 | } 91 | 92 | func setupNode() throws -> TailscaleNode { 93 | guard self.node == nil else { return self.node! } 94 | self.node = try TailscaleNode(config: config, logger: logger) 95 | return self.node! 96 | } 97 | 98 | func phoneHome(_ setMessage: @escaping MessageSender) async { 99 | do { 100 | guard let node, ready else { 101 | await setMessage("Not ready yet!") 102 | return 103 | } 104 | 105 | await setMessage("Phoning " + Settings.tailnetURL + "...") 106 | 107 | // Create a URLSession that can access nodes on the tailnet. 108 | // .tailscaleSession(node) is the magic sauce. This sends your URLRequest via 109 | // userspace Tailscale's SOCKS5 proxy. 110 | let (sessionConfig, _) = try await URLSessionConfiguration.tailscaleSession(node) 111 | let session = URLSession(configuration: sessionConfig) 112 | 113 | // Request a resource from the tailnet... 114 | let url = URL(string: Settings.tailnetURL)! 115 | let req = URLRequest(url: url) 116 | 117 | let (data, _) = try await session.data(for: req) 118 | await setMessage("\(Settings.tailnetURL) says:\n \(String(data: data, encoding: .utf8) ?? "(crickets!)")") 119 | } catch { 120 | await setMessage("Whoops!: \(error)") 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloModel.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import TailscaleKit 5 | 6 | actor HelloModel: MessageConsumer { 7 | private let logger: LogSink 8 | 9 | init(logger: LogSink) { 10 | self.logger = logger 11 | } 12 | 13 | // MARK: - Message Consumer 14 | 15 | // Notify objects contain the Tailnet information we've subscribed to via 16 | // the bus watcher. The state is always included. The netmap is included 17 | // if we add .netmap to the watchopts. 18 | func notify(_ notify: TailscaleKit.Ipn.Notify) { 19 | if let n = notify.NetMap { 20 | netmap = n 21 | peers = n.Peers 22 | netmapHandlers.values.forEach { $0(n) } 23 | peersHandlers.values.forEach { $0(n.Peers) } 24 | 25 | } 26 | 27 | if let s = notify.State { 28 | logger.log("State: \(s)") 29 | state = s 30 | stateHandlers.values.forEach { $0(s) } 31 | } 32 | } 33 | 34 | func error(_ error: any Error) { 35 | logger.log("\(error)") 36 | } 37 | 38 | // MARK: - Stream Publishers 39 | 40 | // Alternatively, use Combine publishers 41 | 42 | var netmap: Netmap.NetworkMap? 43 | var state: Ipn.State? 44 | var peers: [Tailcfg.Node]? 45 | 46 | private var netmapHandlers: [UUID: ((Netmap.NetworkMap?) -> Void)] = [:] 47 | private func removeNetmapHandler(_ uuid: UUID) { 48 | netmapHandlers[uuid] = nil 49 | } 50 | 51 | private var stateHandlers: [UUID: ((Ipn.State?) -> Void)] = [:] 52 | private func removeStateHandler(_ uuid: UUID) { 53 | stateHandlers[uuid] = nil 54 | } 55 | 56 | private var peersHandlers: [UUID: (([Tailcfg.Node]?) -> Void)] = [:] 57 | private func removePeersHandler(_ uuid: UUID) { 58 | peersHandlers[uuid] = nil 59 | } 60 | 61 | var netmapStream: AsyncStream { 62 | AsyncStream { continuation in 63 | let uuid = UUID() 64 | self.netmapHandlers[uuid] = { netmap in 65 | _ = continuation.yield(netmap) 66 | } 67 | continuation.onTermination = { _ in 68 | Task { await self.removeNetmapHandler(uuid) } 69 | } 70 | } 71 | } 72 | 73 | var peersStream: AsyncStream<[Tailcfg.Node]?> { 74 | AsyncStream { continuation in 75 | let uuid = UUID() 76 | 77 | self.peersHandlers[uuid] = { peers in 78 | _ = continuation.yield(peers) 79 | } 80 | continuation.onTermination = { _ in 81 | Task { await self.removePeersHandler(uuid) } 82 | } 83 | } 84 | } 85 | 86 | var stateStream: AsyncStream { 87 | AsyncStream { continuation in 88 | let uuid = UUID() 89 | self.stateHandlers[uuid] = { state in 90 | _ = continuation.yield(state) 91 | } 92 | continuation.onTermination = { _ in 93 | Task { await self.removeStateHandler(uuid) } 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloView.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import SwiftUI 5 | 6 | 7 | struct HelloView: View { 8 | @State var viewModel : HelloViewModel 9 | let dialer: Dialer 10 | 11 | init(dialer: Dialer, model: HelloModel) { 12 | self.dialer = dialer 13 | self.viewModel = HelloViewModel(model: model) 14 | } 15 | 16 | var body: some View { 17 | VStack { 18 | Text("TailscaleKit Sample App. See README.md for setup instructions.") 19 | .font(.title3) 20 | .padding(20) 21 | Spacer(minLength: 5) 22 | Text(viewModel.stateMessage) 23 | Text(viewModel.peerCountMessage) 24 | Spacer(minLength: 5) 25 | Text(viewModel.message) 26 | .font(.title3) 27 | Button("Phone Home") { 28 | viewModel.runRequest(dialer) 29 | } 30 | 31 | } 32 | .padding() 33 | } 34 | } 35 | 36 | actor PreviewDialer: Dialer { 37 | func phoneHome(_ setMessage: @escaping @Sendable (String) async -> Void) async { 38 | await setMessage("Hello from Preview!") 39 | } 40 | } 41 | 42 | #Preview { 43 | let d = PreviewDialer() 44 | HelloView(dialer: d, model: HelloModel(logger: Logger())) 45 | } 46 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloViewModel.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | 5 | import SwiftUI 6 | @preconcurrency import Combine 7 | import TailscaleKit 8 | 9 | @Observable 10 | class HelloViewModel: @unchecked Sendable { 11 | var message: String = "Ready to phone home!" 12 | var peerCountMessage = "Waiting for peers...." 13 | var stateMessage = "Waiting for state...." 14 | 15 | var modelObservers = [Task]() 16 | 17 | @MainActor 18 | init(model: HelloModel) { 19 | bindToModel(model) 20 | } 21 | 22 | deinit { 23 | modelObservers.forEach { $0.cancel() } 24 | } 25 | 26 | @MainActor 27 | func handleStateChange(_ state: Ipn.State?) { 28 | guard let state else { 29 | self.stateMessage = "Waiting for state...." 30 | return 31 | } 32 | self.stateMessage = "IPNState: \(state)" 33 | } 34 | 35 | @MainActor 36 | func handlePeersChange(_ peers: [Tailcfg.Node]?) { 37 | guard let peers else { 38 | self.peerCountMessage = "Waiting for peers..." 39 | return 40 | } 41 | 42 | if peers.count > 0 { 43 | self.peerCountMessage = "Found \(peers.count) peers" 44 | } else { 45 | self.peerCountMessage = "No peers found" 46 | } 47 | } 48 | 49 | @MainActor 50 | func bindToModel(_ model: HelloModel) { 51 | modelObservers.forEach { $0.cancel() } 52 | modelObservers.removeAll() 53 | 54 | Task { 55 | await handleStateChange(model.state) 56 | await handlePeersChange(model.peers) 57 | } 58 | 59 | modelObservers.append( Task { [weak self] in 60 | for await peers in await model.peersStream { 61 | if Task.isCancelled { return } 62 | guard let self = self else { return } 63 | await MainActor.run { handlePeersChange(peers) } 64 | } 65 | }) 66 | 67 | modelObservers.append( Task { [weak self] in 68 | for await state in await model.stateStream { 69 | if Task.isCancelled { return } 70 | guard let self = self else { return } 71 | await MainActor.run { handleStateChange(state) } 72 | } 73 | }) 74 | } 75 | 76 | @MainActor 77 | func setMessage(_ message: String) { 78 | self.message = message 79 | } 80 | 81 | func runRequest(_ dialer: Dialer) { 82 | Task { 83 | let model = self 84 | await dialer.phoneHome { msg in 85 | await MainActor.run { 86 | model.setMessage(msg) 87 | } 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSExceptionDomains 8 | 9 | ts.net 10 | 11 | NSExceptionAllowsInsecureHTTPLoads 12 | 13 | NSIncludesSubdomains 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/Info_iOS.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSExceptionDomains 8 | 9 | ts.net 10 | 11 | NSExceptionAllowsInsecureHTTPLoads 12 | 13 | NSIncludesSubdomains 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/HelloFromTailscale/TailnetSettings.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Foundation 5 | 6 | struct Settings { 7 | // Replace with an actual auth key generated from the Tailscale admin console 8 | static let authKey = "tskey-auth-your-auth-key" 9 | // Note: The sample has a transport exception for http on ts.net so http:// is ok... 10 | // The "Phone Home" button will load the contents of this URL, it should be on your Tailnet. 11 | static let tailnetURL = "http://myserver.my-tailnet.ts.net" 12 | // Identifies this application in the Tailscale admin console. 13 | static let hostName = "Hello-From-Tailsacle-Sample-App" 14 | } 15 | 16 | 17 | func getDocumentDirectoryPath() -> URL { 18 | let arrayPaths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) 19 | let docDirectoryPath = arrayPaths[0] 20 | return docDirectoryPath 21 | } 22 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/README.md: -------------------------------------------------------------------------------- 1 | # TailscaleKitHello 2 | 3 | ## Instructions 4 | 5 | First build TailscaleKit for the platform you wish to target: 6 | 7 | From /swift: 8 | ``` 9 | $ make macos 10 | $ make ios-fat 11 | ``` 12 | 13 | The ios target expects the universal xcframework produced by make ios-fat and 14 | can be run on either a device or the simulator. 15 | 16 | In TailnetSettings, configure an auth key and a server/service to query. 17 | 18 | ``` 19 | let authKey = "your-auth-key-here" 20 | let tailnetServer = "http://your-server-here.your-tailnet.ts.net" 21 | ``` 22 | 23 | Run the project. Phone Home. The output should be the response from the server. 24 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/TailscaleKitHello.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/TailscaleKitHello.xcodeproj/xcshareddata/xcschemes/HelloFromTailscale iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /swift/Examples/TailscaleKitHello/TailscaleKitHello.xcodeproj/xcshareddata/xcschemes/HelloFromTailscale_iOS_Sim.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /swift/Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | ## Prettifies xcode output for xcode tests using xcpretty, if it is installed 5 | XCPRETTIFIER := xcpretty 6 | ifeq (, $(shell which $(XCPRETTIFIER))) 7 | XCPRETTIFIER := cat 8 | endif 9 | 10 | # The xcodebuild schemes will run the Makefile in the root directory to build 11 | # the libtailscale.a and libtailscale_ios.a dependencies. 12 | 13 | .PHONY: all 14 | all: test ios macos ios-fat ## Runs the tests and builds all library targets 15 | 16 | .PHONY: macos 17 | macos: ## Builds TailscaleKit for macos to swift/build/Build/Products/Release (unsigned) 18 | @echo 19 | @echo "::: Building TailscaleKit.framework for macOS :::" 20 | cd .. && make c-archive 21 | mkdir -p build 22 | xcodebuild build -scheme "TailscaleKit (macOS)" \ 23 | -derivedDataPath build \ 24 | -configuration Release \ 25 | -destination 'platform=macOS,arch=arm64' \ 26 | CODE_SIGNING_ALLOWED=NO | $(XCPRETTIFIER) 27 | 28 | .PHONY: ios 29 | ios: ## Builds TailscaleKit for iOS to swift/build/Build/Products/Release-iphoneos (unsigned) 30 | @echo 31 | @echo "::: Building TailscaleKit.framework for iOS :::" 32 | cd .. && make c-archive-ios 33 | mkdir -p build 34 | xcodebuild build -scheme "TailscaleKit (iOS)" \ 35 | -derivedDataPath build \ 36 | -configuration Release \ 37 | -destination 'generic/platform=iOS' \ 38 | CODE_SIGNING_ALLOWED=NO | $(XCPRETTIFIER) 39 | 40 | .PHONY: ios-sim 41 | ios-sim: ## Builds TailscaleKit for iOS to swift/build/Build/Products/Release-iphonesimulator (unsigned) 42 | @echo 43 | @echo "::: Building TailscaleKit.framework for iOS Simulator :::" 44 | cd .. && make c-archive-ios-sim 45 | mkdir -p build 46 | xcodebuild build -scheme "TailscaleKit (Simulator)" \ 47 | -derivedDataPath build \ 48 | -configuration Release \ 49 | -destination 'generic/platform=iOS Simulator' \ 50 | CODE_SIGNING_ALLOWED=NO | $(XCPRETTIFIER) 51 | 52 | .PHONY: ios-fat 53 | ios-fat: ios-sim ios ## Builds TailscaleKit.xcframework to swift/build/Build/Products/Release-iphonefat 54 | @echo 55 | @echo "::: Building TailscaleKit.xcframework for ios and ios-simulator :::" 56 | mkdir -p ./build/Build/Products/Release-iphonefat 57 | xcodebuild -create-xcframework \ 58 | -framework ./build/Build/Products/Release-iphoneos/TailscaleKit.framework \ 59 | -framework ./build/Build/Products/Release-iphonesimulator/TailscaleKit.framework \ 60 | -output ./build/Build/Products/Release-iphonefat/TailscaleKit.xcframework 61 | 62 | .PHONY: test 63 | test: ## Run tests (macOS) 64 | @echo 65 | @echo "::: Running tests for TailscaleKit :::" 66 | cd ../tstestcontrol && make all 67 | cd .. && make c-archive 68 | mkdir -p build 69 | xcodebuild build-for-testing -scheme TailscaleKitXCTests \ 70 | -derivedDataPath build \ 71 | -configuration Debug \ 72 | -quiet \ 73 | -destination 'platform=macOS,arch=arm64' \ 74 | CODE_SIGNING_ALLOWED=NO 75 | xcodebuild test-without-building -scheme TailscaleKitXCTests \ 76 | -derivedDataPath build \ 77 | -configuration Debug \ 78 | -destination 'platform=macOS,arch=arm64' \ 79 | CODE_SIGNING_ALLOWED=NO 80 | 81 | .PHONY: clean 82 | clean: ## Clean up build artifacts (including the libtailscale dependencies) 83 | cd .. && make clean 84 | rm -rf build 85 | 86 | .PHONY: help 87 | help: ## Show this help 88 | @echo "\nSpecify a command. The choices are:\n" 89 | @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-12s\033[m %s\n", $$1, $$2}' 90 | @echo "" 91 | 92 | .DEFAULT_GOAL := help 93 | -------------------------------------------------------------------------------- /swift/README.md: -------------------------------------------------------------------------------- 1 | # TailscaleKit 2 | 3 | The TailscaleKit Swift package provides an embedded network interface that can be 4 | used to listen for and dial connections to other [Tailscale](https://tailscale.com) nodes in addition 5 | to an extension to URLSession which allows you to make URL requests to nodes on you Tailnet directly. 6 | 7 | The interfaces are similar in design to NWConnection, but are Swift 6 compliant and 8 | designed to be used in modern async/await style code. 9 | 10 | ## Build and Install 11 | 12 | Build Requirements: 13 | - XCode 16.1 or newer 14 | 15 | Building Tailscale.framework: 16 | 17 | From /swift 18 | ```bash 19 | $ make macos 20 | $ make ios 21 | $ make ios-sim 22 | $ make ios-fat 23 | ``` 24 | 25 | These recipes build different variants of TailscaleKit.framework into /swift/build/Build/Products. 26 | 27 | Separate frameworks will be built for macOS and iOS and the iOS Simulator. All dependencies (libtailscale*.a) 28 | are built automatically. Swift 6 is supported. 29 | 30 | The ios and ios-sim frameworks are purposefully separated. The former is free of any simulator segments 31 | and is suitable for app-store submissions. The latter is suitable for embedding when you 32 | wish to run on a simulator in dev though 'make ios-fat' will produce an xcframework bundle including 33 | both simulator and device frameworks for development. 34 | 35 | The frameworks are not signed and must be signed when they are embedded. 36 | 37 | Alternatively, you may build from xCode using the Tailscale scheme but the 38 | libraries must be built first (since xCode will complain about paths and 39 | permissions) 40 | 41 | To build only the static libraries, from / 42 | ```bash 43 | $ make c-archive 44 | $ make c-archive-ios 45 | $ make c-archive-ios-sim 46 | ``` 47 | 48 | If you're writing pure C, or C++, link these and use the generated tailscale.h header. 49 | make c-archive builds for the local machine architecture/platform (arm64 macOS from a mac) 50 | 51 | Non-apple swift builds are not supported (yet) but should be possible with a little tweaking. 52 | 53 | ## Tests 54 | 55 | From /swift 56 | ```bash 57 | $ make test 58 | ``` 59 | 60 | 61 | ## Usage 62 | 63 | Nodes need to be authorized in order to function. Set an auth key via 64 | the config.authKey parameter, or watch the ipn bus (see the example) for 65 | the browseToURL field for interactive web-based auth. 66 | 67 | Here's a working example using an auth key: 68 | 69 | ```Swift 70 | func start() -> TailscaleNode { 71 | let dataDir = getDocumentDirectoryPath().absoluteString + "tailscale" 72 | let authKey = "tsnet-auth-put-your-auth-key-key-here" 73 | let config = Configuration(hostName: "TSNet-Test", 74 | path: dataDir, 75 | authKey: authKey, 76 | controlURL: Configuration.defaultControlURL, 77 | ephemeral: false) 78 | 79 | // The logger is configurable. The default will just print. 80 | let node = try TailscaleNode(config: config, logger: DefaultLogger()) 81 | 82 | // Bring the node up 83 | try await node.up() 84 | return node 85 | } 86 | 87 | // Do a URL request via the loopback proxy 88 | // Where url is a node on your tailnet such as https://server.fiesty-pangolin.ts.net/thing 89 | func fetchURL(_ url: URL, tailscale: TailscaleNode) async throws -> Data { 90 | // You can cache this. It will not change once the node is up. 91 | let sessionConfig = try await URLSessionConfiguration.tailscaleSession(tailscale) 92 | let session = URLSession(configuration: sessionConfig) 93 | 94 | // Make the request 95 | let req = URLRequest(url: url) 96 | let (data, _) = try await session.data(for: req) 97 | return data 98 | } 99 | ``` 100 | 101 | The "node" created here should show up in the Tailscale admin panel as "TSNet-Test" 102 | 103 | ### LocalAPI 104 | 105 | TailscaleKit.framework also includes a functional (though somewhat incomplete) implementation of 106 | LocalAPI which can be used to track the state of the embedded tailscale instance in much greater 107 | detail. 108 | 109 | ### Examples 110 | 111 | See the TailscaleKitHello example for a relatively complete implementation demonstrating proxied 112 | HTTP and usage of LocalAPI to track the tailnet state. 113 | 114 | ## Contributing 115 | 116 | Pull requests are welcome on GitHub at https://github.com/tailscale/libtailscale 117 | 118 | Please file any issues about this code or the hosted service on 119 | [the issue tracker](https://github.com/tailscale/tailscale/issues). 120 | -------------------------------------------------------------------------------- /swift/TailscaleKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /swift/TailscaleKit.xcodeproj/xcshareddata/xcschemes/TailscaleKit (Simulator).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /swift/TailscaleKit.xcodeproj/xcshareddata/xcschemes/TailscaleKit (iOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /swift/TailscaleKit.xcodeproj/xcshareddata/xcschemes/TailscaleKit (macOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /swift/TailscaleKit.xcodeproj/xcshareddata/xcschemes/TailscaleKitTestHost.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 47 | 48 | 49 | 50 | 51 | 61 | 63 | 69 | 70 | 71 | 72 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /swift/TailscaleKit.xcodeproj/xcshareddata/xcschemes/TailscaleKitXCTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 20 | 26 | 27 | 28 | 29 | 30 | 40 | 41 | 47 | 48 | 50 | 51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /swift/TailscaleKit.xcodeproj/xcshareddata/xcschemes/libtailscale (ios).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /swift/TailscaleKit.xcodeproj/xcshareddata/xcschemes/libtailscale (macOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /swift/TailscaleKit.xcodeproj/xcshareddata/xcschemes/libtstestcontrol.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /swift/TailscaleKit/IncomingConnection.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Combine 5 | import Foundation 6 | 7 | /// IncomingConnection is use to read incoming message from an inbound 8 | /// connection. IncomingConnections are not instantiated directly, 9 | /// they are returned by Listener.accept 10 | public actor IncomingConnection { 11 | private let logger: LogSink? 12 | private var conn: TailscaleConnection = 0 13 | private let reader: SocketReader 14 | 15 | public let remoteAddress: String? 16 | 17 | @Published var _state: ConnectionState = .idle 18 | 19 | public func state() -> any AsyncSequence { 20 | $_state 21 | .removeDuplicates() 22 | .eraseToAnyPublisher() 23 | .values 24 | } 25 | 26 | init(conn: TailscaleConnection, remoteAddress: String?, logger: LogSink? = nil) async { 27 | self.logger = logger 28 | self.conn = conn 29 | _state = .connected 30 | self.remoteAddress = remoteAddress 31 | reader = SocketReader(conn: conn) 32 | } 33 | 34 | deinit { 35 | if conn != 0 { 36 | unistd.close(conn) 37 | } 38 | } 39 | 40 | public func close() { 41 | if conn != 0 { 42 | unistd.close(conn) 43 | conn = 0 44 | } 45 | _state = .closed 46 | } 47 | 48 | /// Returns up to size bytes from the connection. Blocks until 49 | /// data is available 50 | public func receive(maximumLength: Int = 4096, timeout: Int32) async throws -> Data { 51 | guard _state == .connected else { 52 | throw TailscaleError.connectionClosed 53 | } 54 | 55 | return try await reader.read(timeout: timeout, len: maximumLength) 56 | } 57 | 58 | /// Reads a complete message from the connection 59 | public func receiveMessage( timeout: Int32) async throws -> Data { 60 | guard _state == .connected else { 61 | throw TailscaleError.connectionClosed 62 | } 63 | 64 | return try await reader.readAll(timeout: timeout) 65 | } 66 | } 67 | 68 | /// Serializes read operations from an IncomingConnection 69 | private actor SocketReader { 70 | // We'll read in 2048 byte chunks which should be sufficient to hold the payload 71 | // of a single packet 72 | private static let maxBufferSize = 2048 73 | private let conn: TailscaleConnection 74 | private var buffer = [UInt8](repeating:0, count: maxBufferSize) 75 | 76 | init(conn: TailscaleConnection) { 77 | self.conn = conn 78 | } 79 | 80 | func read(timeout: Int32, len: Int) throws -> Data { 81 | var p: pollfd = .init(fd: conn, events: Int16(POLLIN), revents: 0) 82 | let res = poll(&p, 1, timeout) 83 | guard res > 0 else { 84 | throw TailscaleError.readFailed 85 | } 86 | 87 | let bytesToRead = min(len, Self.maxBufferSize) 88 | var bytesRead = 0 89 | buffer.withUnsafeMutableBufferPointer { ptr in 90 | bytesRead = unistd.read(conn, ptr.baseAddress, bytesToRead) 91 | } 92 | 93 | if bytesRead < 0 { 94 | throw TailscaleError.readFailed 95 | } 96 | return Data(buffer[0.. Data { 100 | var data: Data = .init() 101 | while true { 102 | let read = try read(timeout: timeout, len: Self.maxBufferSize) 103 | data.append(read) 104 | if read.count < Self.maxBufferSize { 105 | break 106 | } 107 | } 108 | return data 109 | } 110 | } 111 | 112 | -------------------------------------------------------------------------------- /swift/TailscaleKit/Listener.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Combine 5 | import Foundation 6 | 7 | /// A Listener is used to await incoming connections from another 8 | /// Tailnet node. 9 | public actor Listener { 10 | private var tailscale: TailscaleHandle 11 | private var listener: TailscaleListener = 0 12 | private var proto: NetProtocol 13 | private var address: String 14 | 15 | private let logger: LogSink? 16 | 17 | @Published var _state: ListenerState = .idle 18 | 19 | public func state() -> any AsyncSequence { 20 | $_state 21 | .removeDuplicates() 22 | .eraseToAnyPublisher() 23 | .values 24 | } 25 | 26 | /// Initializes and readies a new listener 27 | /// 28 | /// @param tailscale A handle to a Tailscale server 29 | /// @param proto The ip protocol to listen for 30 | /// @param address The address (ip:port or port) to listen on 31 | /// @param logger An optional LogSink 32 | public init(tailscale: TailscaleHandle, 33 | proto: NetProtocol, 34 | address: String, 35 | logger: LogSink? = nil) async throws { 36 | self.logger = logger 37 | self.tailscale = tailscale 38 | self.address = address 39 | self.proto = proto 40 | 41 | let res = tailscale_listen(tailscale, proto.rawValue, address, &listener) 42 | 43 | guard res == 0 else { 44 | _state = .failed 45 | let msg = tailscale.getErrorMessage() 46 | let err = TailscaleError.fromPosixErrCode(res, msg) 47 | logger?.log("Listener failed to initialize: \(msg) (\(err.localizedDescription))") 48 | throw err 49 | } 50 | _state = .listening 51 | } 52 | 53 | deinit { 54 | if listener != 0 { 55 | unistd.close(listener) 56 | } 57 | } 58 | 59 | /// Closes the listener. It cannot be restarted 60 | /// Listeners will be closed automatically on deallocation 61 | public func close() { 62 | if listener != 0 { 63 | unistd.close(listener) 64 | listener = 0 65 | } 66 | _state = .closed 67 | } 68 | 69 | /// Blocks and awaits a new incoming connection 70 | /// 71 | /// @See tailscale_accept in Tailscale.h 72 | /// @See tailscale_getremoteaddr in Tailscale.h 73 | /// 74 | /// @param timeout The timeout for the underlying poll(2) in seconds. This has a maximum 75 | /// value of Int32.max ms and supports millisecond precision per poll(2) 76 | /// @throws TailscaleError on failure or timeout 77 | /// @returns An incoming connection from which you can receive() Data 78 | public func accept(timeout: TimeInterval = 60) async throws -> IncomingConnection { 79 | if timeout * 1000 > Double(Int32.max) || timeout < 0 { 80 | throw TailscaleError.invalidTimeout 81 | } 82 | 83 | logger?.log("Listening for \(proto.rawValue) on \(address)") 84 | 85 | var p: pollfd = .init(fd: listener, events: Int16(POLLIN), revents: 0) 86 | let ret = poll(&p, 1, Int32(timeout * 1000)) 87 | guard ret > 0 else { 88 | close() 89 | throw TailscaleError.fromPosixErrCode(errno, "Poll failed") 90 | } 91 | 92 | logger?.log("Accepting \(proto.rawValue) connection via \(address)") 93 | guard listener != 0 else { 94 | close() 95 | throw TailscaleError.listenerClosed 96 | } 97 | 98 | var connfd: Int32 = 0 99 | let res = tailscale_accept(listener, &connfd) 100 | guard res == 0 else { 101 | close() 102 | let msg = tailscale.getErrorMessage() 103 | throw TailscaleError.fromPosixErrCode(res, msg) 104 | } 105 | 106 | /// We extract the remove address here for utility so you know 107 | /// who's calling, so you can dial back. 108 | var remoteAddress: String? 109 | var buffer = [Int8](repeating:0, count: 64) 110 | buffer.withUnsafeMutableBufferPointer { buf in 111 | let err = tailscale_getremoteaddr(listener, connfd, buf.baseAddress, 64) 112 | if err == 0 { 113 | remoteAddress = String(cString: buf.baseAddress!) 114 | } else { 115 | let msg = tailscale.getErrorMessage() 116 | let err = TailscaleError.fromPosixErrCode(err, msg) 117 | logger?.log("Failed to get remote address: \(msg) \(err.localizedDescription)") 118 | // Do not throw here. Lack of a remote address is not fatal 119 | // The caller can directly invoke server.addrs() if required. 120 | } 121 | } 122 | 123 | logger?.log("Accepted \(proto.rawValue) fd:\(connfd) from:\(remoteAddress ?? "unknown")") 124 | return await IncomingConnection(conn: connfd, 125 | remoteAddress: remoteAddress, 126 | logger: logger) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /swift/TailscaleKit/LocalAPI/GoTime.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Foundation 5 | 6 | extension Date { 7 | var isGoZeroTime: Bool { 8 | return self == GoZeroTime 9 | } 10 | } 11 | 12 | // GoZeroTime is a Date instance that matches Go's time.Time zero value. It's 13 | // explicitly created using Go's zero value (0001-01-01, 00:00:00 UTC), since 14 | // using an offset from the Unix epoch (1970-01-01, 00:00:00 UTC) does not work 15 | // as expected on macOS 10.13. 16 | let GoZeroTimeString = "0001-01-01T00:00:00Z" 17 | let GoZeroTime = ISO8601DateFormatter().date(from: GoZeroTimeString)! 18 | 19 | extension String { 20 | func iso8601Date() -> Date? { 21 | let iso8601DateFormatter = { 22 | ISO8601DateFormatter() 23 | }() 24 | 25 | let iso8601DateFormatterFractionalSeconds: ISO8601DateFormatter = { 26 | let dateFormatter = ISO8601DateFormatter() 27 | dateFormatter.formatOptions.insert(.withFractionalSeconds) 28 | return dateFormatter 29 | }() 30 | 31 | // Fractional seconds are optional in RFC3339 as generated by Go/control, 32 | // but Foundation date formatters do not parse dates with and without 33 | // fractional seconds without specifying the option to look for them. 34 | if let date = iso8601DateFormatterFractionalSeconds.date(from: self) { 35 | return date 36 | } 37 | if let date = iso8601DateFormatter.date(from: self) { 38 | return date 39 | } 40 | return nil 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /swift/TailscaleKit/LocalAPI/MessageProcessor.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Foundation 5 | 6 | let kJsonNewline = UInt8(ascii: "\n") 7 | 8 | /// The polling interval for the message queue 9 | let kProcessorQueuePollInterval: UInt64 = 100_000_000 // Nanos 10 | 11 | /// A MessageConsumer consumes incoming messages from the IPNBus and handles any 12 | /// potential errors. 13 | public protocol MessageConsumer: Actor { 14 | func notify(_ notify: Ipn.Notify) 15 | func error(_ error: Error) 16 | } 17 | 18 | 19 | /// MessageProcessor pulls queued Decodable messages from a MessageReader, deserializes them 20 | /// and forwards the deserialized objects and any errors to the consumer. 21 | public class MessageProcessor: @unchecked Sendable { 22 | let consumer: any MessageConsumer 23 | let reader: MessageReader 24 | let workQueue = OperationQueue() 25 | var logger: LogSink? 26 | 27 | 28 | // A long running task to poll the queue 29 | var pollTask: Task? 30 | 31 | init(consumer: any MessageConsumer, logger: LogSink?) async { 32 | workQueue.maxConcurrentOperationCount = 1 33 | workQueue.name = "io.tailscale.ipn.MessageProcessor.workQueue" 34 | 35 | self.logger = logger 36 | self.consumer = consumer 37 | self.reader = MessageReader() 38 | } 39 | 40 | deinit { 41 | cancel() 42 | reader.stop() 43 | } 44 | 45 | func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: (@Sendable (Error) -> Void)? = nil) { 46 | workQueue.addOperation { [weak self] in 47 | guard let self = self else { return } 48 | logger?.log("Starting MessageProcessor for \(request.url?.absoluteString ?? "nil")") 49 | cancel() 50 | let errorHandler = errorHandler ?? { [weak self] error in 51 | self?.processError(error) 52 | } 53 | 54 | reader.start(request, config: config, errorHandler: errorHandler) 55 | startMessageQueuePoll() 56 | } 57 | } 58 | 59 | public func cancel() { 60 | pollTask?.cancel() 61 | } 62 | 63 | func startMessageQueuePoll() { 64 | pollTask?.cancel() 65 | pollTask = Task { 66 | await watchMessageQueue() 67 | } 68 | } 69 | 70 | func watchMessageQueue() async { 71 | logger?.log("Watching MessageReader") 72 | while !Task.isCancelled { 73 | reader.consume { [weak self] data in 74 | if let data { 75 | self?.processMessage(data) 76 | } 77 | } 78 | try? await Task.sleep(nanoseconds: kProcessorQueuePollInterval) 79 | } 80 | logger?.log("Unwatching MessageReader") 81 | } 82 | 83 | func processMessage(_ data: Data) { 84 | workQueue.addOperation { [weak self] in 85 | guard let self else { return } 86 | let lines = data.split(separator: kJsonNewline) 87 | for line in lines { 88 | do { 89 | let notify = try JSONDecoder().decode(Ipn.Notify.self, from: line) 90 | Task { 91 | await consumer.notify(notify) 92 | } 93 | } catch { 94 | logger?.log("Failed to decode message: \(String(data: line, encoding: .utf8) ?? "nil")") 95 | } 96 | } 97 | } 98 | } 99 | 100 | func processError(_ error: Error) { 101 | Task { 102 | await consumer.error(error) 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /swift/TailscaleKit/LocalAPI/MessageReader.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Foundation 5 | 6 | enum MessageQueueError: Error { 7 | case queueCongested 8 | } 9 | 10 | /// The maximum number of unprocessed messages that can be queued before we start discarding 11 | /// This needs to be large enough to handle the bursty "first time" connection messages but 12 | /// small enough to avoid our memory footprint growing arbitrarily large. 13 | let kMaxQueueSize = 24 14 | 15 | /// Provides a queue for incoming messages on the IPN bus. This will keep a maximum of 16 | /// the last kMaxQueueSize inbound messages pending processing. If the queue is congested, we will 17 | /// stop queueing messages and throw an error once the queue has been drained. 18 | final class MessageReader: NSObject, URLSessionDataDelegate, @unchecked Sendable { 19 | /// All mutation and reading of local state happens in workQueue. 20 | let workQueue = OperationQueue() 21 | 22 | /// Holds partial incoming messages 23 | var buffer: Data = Data() 24 | var ipnWatchSession: URLSession? 25 | var dataTask: URLSessionDataTask? 26 | 27 | var logger: LogSink? 28 | 29 | /// FIFO queue for messages awaiting processing 30 | var pendingMessages: [Data] = [] 31 | 32 | /// Once congested, we will allow the processor to empty the queue, but we will stop queueing messages. 33 | /// consume()ing the last messages will trigger a MessageQueueError.queueCongested error which the 34 | /// upstream consumer can use. Typically, this means we lost messages, so the correct action is to 35 | /// restart the processor and queue with an .initialState flag. 36 | var congested = false 37 | 38 | var errorHandler: (@Sendable (Error) -> Void)? 39 | 40 | init(logger: LogSink? = nil) { 41 | self.logger = logger 42 | workQueue.maxConcurrentOperationCount = 1 43 | workQueue.name = "io.tailscale.ipn.MessageReader.workQueue" 44 | } 45 | 46 | func stop() { 47 | ipnWatchSession?.invalidateAndCancel() 48 | workQueue.cancelAllOperations() 49 | } 50 | 51 | func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: @escaping @Sendable (Error) -> Void ) { 52 | workQueue.addOperation { [weak self] in 53 | guard let self = self else { return } 54 | 55 | self.errorHandler = errorHandler 56 | 57 | buffer = Data() 58 | pendingMessages = [] 59 | congested = false 60 | 61 | dataTask?.cancel() 62 | ipnWatchSession?.invalidateAndCancel() 63 | 64 | ipnWatchSession = URLSession(configuration: config, 65 | delegate: self, 66 | delegateQueue: workQueue) 67 | 68 | dataTask = ipnWatchSession?.dataTask(with: request) 69 | dataTask?.resume() 70 | } 71 | } 72 | 73 | func consume(_ completion: @escaping @Sendable (Data?) -> Void) { 74 | workQueue.addOperation { [weak self] in 75 | guard let self else { return } 76 | if congested && pendingMessages.count == 0 { 77 | errorHandler?(MessageQueueError.queueCongested) 78 | completion(nil) 79 | return 80 | } 81 | 82 | guard pendingMessages.count > 0 else { 83 | completion(nil) 84 | return 85 | } 86 | completion(pendingMessages.removeFirst()) 87 | } 88 | } 89 | 90 | // MARK: - URLSessionDataDelegate 91 | 92 | func urlSession(_ session: URLSession, 93 | task: URLSessionTask, 94 | didCompleteWithError error: Error?) { 95 | if let error = error { 96 | let nsError = error as NSError 97 | // Ignore cancellation errors, those are deliberate. 98 | if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { 99 | return 100 | } 101 | errorHandler?(error) 102 | } 103 | } 104 | 105 | func urlSession(_ session: URLSession, 106 | dataTask: URLSessionDataTask, 107 | didReceive data: Data) { 108 | if congested { 109 | return 110 | } 111 | receiveData(data) 112 | } 113 | 114 | func receiveData(_ data: Data) { 115 | workQueue.addOperation { [weak self] in 116 | guard let self else { return } 117 | 118 | buffer.append(data) 119 | if buffer[buffer.count - 1] == kJsonNewline { 120 | if pendingMessages.count >= kMaxQueueSize { 121 | congested = true 122 | return 123 | } 124 | pendingMessages.append(buffer) 125 | buffer.removeAll(keepingCapacity: true) 126 | } 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /swift/TailscaleKit/LogSink.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Foundation 5 | 6 | /// A generic interface for sinking log messages from the Swift wrapper 7 | /// and go 8 | public protocol LogSink: Sendable { 9 | /// An optional file handle. The go backend will write all internal logs 10 | /// to this. STDOUT_FILENO or a handle to a writable file. 11 | var logFileHandle: Int32? { get } 12 | 13 | /// Called for swift internal logs. 14 | func log(_ message: String) 15 | } 16 | 17 | /// Dumps all internal logs to NSLog and go logs to stdout 18 | public struct DefaultLogger: LogSink { 19 | public var logFileHandle: Int32? = STDOUT_FILENO 20 | 21 | public func log(_ message: String) { 22 | NSLog(message) 23 | } 24 | } 25 | 26 | /// Discards all logs 27 | public struct BlackholeLogger: LogSink { 28 | public var logFileHandle: Int32? 29 | 30 | public func log(_ message: String) { 31 | // Go back to the Shadow! 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /swift/TailscaleKit/OutgoingConnection.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Foundation 5 | import Combine 6 | 7 | /// ConnectionState indicates the state of individual TSConnection instances 8 | public enum ConnectionState { 9 | case idle ///< Reads and writes are not possible. Connections will transition to connected automatically 10 | case connected ///< Connected and ready to read/write 11 | case closed ///< Closed and ready to be disposed of. Closed connections cannot be reconnected. 12 | case failed ///< The attempt to dial the connection failed 13 | } 14 | 15 | /// ListenerState indicates the state of individual TSListener instances 16 | public enum ListenerState { 17 | case idle ///< Waiting. 18 | case listening ///< Listening 19 | case closed ///< Closed and ready to be disposed of. 20 | case failed ///< The attempt to start the listener failed 21 | } 22 | 23 | public typealias TailscaleHandle = Int32 24 | public typealias TailscaleConnection = Int32 25 | public typealias TailscaleListener = Int32 26 | 27 | /// Outgoing connections are used to send data to other endpoints 28 | /// on the tailnet. 29 | /// 30 | /// For HTTP(s), consider using URLSession.tailscaleSession 31 | public actor OutgoingConnection { 32 | private var tailscale: TailscaleHandle 33 | private var proto: NetProtocol 34 | private var address: String 35 | private var conn: TailscaleConnection = 0 36 | 37 | private let logger: LogSink 38 | 39 | /// The state of the connection. Listen for transitions to determine 40 | /// if the connection may be used for send/receive operations. 41 | public var state: ConnectionState = .idle 42 | 43 | /// Creates a new outgoing connection 44 | /// 45 | /// @param tailscale The tailscale Server to use 46 | /// @param address The remote address and port 47 | /// @param proto The ip protocol 48 | /// @param logger 49 | /// 50 | /// @throws TailscaleError on failure 51 | public init(tailscale: TailscaleHandle, 52 | to address: String, 53 | proto: NetProtocol, 54 | logger: LogSink) async throws { 55 | 56 | self.logger = logger 57 | self.proto = proto 58 | self.address = address 59 | self.tailscale = tailscale 60 | } 61 | 62 | /// Connects the outgoing connection to the remote. On success, the 63 | /// connection state will be .connected. 64 | /// 65 | /// @See tailscale_dial in Tailscale.h 66 | /// 67 | /// @throws TailscaleError on failure 68 | public func connect() async throws { 69 | let res = tailscale_dial(tailscale, proto.rawValue, address, &conn) 70 | 71 | guard res == 0 else { 72 | self.state = .failed 73 | throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage()) 74 | } 75 | 76 | self.state = .connected 77 | } 78 | 79 | deinit { 80 | if conn != 0 { 81 | unistd.close(conn) 82 | } 83 | } 84 | 85 | /// Closes the outgoing connection. Further sends are not possible. 86 | /// Connections will be closed on deallocation. Sets the connection 87 | /// state to .closed 88 | public func close() { 89 | if conn != 0 { 90 | unistd.close(conn) 91 | conn = 0 92 | } 93 | state = .closed 94 | } 95 | 96 | /// Sends the given data to the connection 97 | /// 98 | /// @throws TailscaleError on failure 99 | public func send(_ data: Data) throws { 100 | guard state == .connected else { 101 | throw TailscaleError.connectionClosed 102 | } 103 | 104 | let bytesWritten = unistd.write(conn, data.withUnsafeBytes { $0.baseAddress! }, data.count) 105 | 106 | if bytesWritten != data.count { 107 | throw TailscaleError.shortWrite 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /swift/TailscaleKit/TailscaleError.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import Foundation 5 | 6 | public enum TailscaleError: Error { 7 | case badInterfaceHandle ///< The tailscale handle is bad. 8 | case listenerClosed ///< The listener was closed and cannot accept new connections 9 | case invalidTimeout ///< The provided listener timeout is invalid 10 | case connectionClosed ///< The underlying connection is closed 11 | case readFailed ///< Read failed 12 | case shortWrite ///< Some data was not written to the connection 13 | case invalidProxyAddress ///< Some data was not written to the connection 14 | case invalidControlURL ///< The provided control URL is invalid 15 | 16 | case cannotFetchIps(_ details: String? = nil) ///< The IPs for the Tailscale server could not be read 17 | case posixError(_ err: POSIXError, _ details: String? = nil) ///< A posix error was thrown with the given err code and details 18 | case unknownPosixError(_ err: Int32, _ details: String? = nil) ///< An unknown posix error occurred 19 | case internalError(_ details: String? = nil) ///< A generic internal error occurred 20 | 21 | /// Create a Tailscale error from an underlying posix error code 22 | static func fromPosixErrCode(_ code: Int32, _ details: String? = nil) -> TailscaleError { 23 | if code == -1 { 24 | return .internalError(details) 25 | } 26 | if let code = POSIXErrorCode(rawValue: code){ 27 | return .posixError( POSIXError(code)) 28 | } 29 | return unknownPosixError(code, details) 30 | } 31 | } 32 | 33 | 34 | extension TailscaleHandle { 35 | static let kMaxErrorMessageLength: Int = 256 36 | 37 | /// Returns the last error message in the Tailscale server as a string. 38 | /// Handles messages up to kMaxErrorMessageLength bytes only. 39 | internal func getErrorMessage() -> String { 40 | let buf = UnsafeMutablePointer.allocate(capacity: Self.kMaxErrorMessageLength) 41 | defer { 42 | buf.deallocate() 43 | 44 | } 45 | let res = tailscale_errmsg(self, buf, 256) 46 | if res != 0 { 47 | switch res { 48 | case EBADF: 49 | return "Bad file descriptor" 50 | case ERANGE: 51 | return "Error message buffer too small" 52 | default: 53 | return "Error fetch failure: \(res)" 54 | } 55 | } 56 | return String(cString: buf) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /swift/TailscaleKit/TailscaleKit.docc/Tailscale.md: -------------------------------------------------------------------------------- 1 | # ``TailscaleKit`` 2 | 3 | Swift 6 wrapper for libtailscale 4 | -------------------------------------------------------------------------------- /swift/TailscaleKit/TailscaleKit.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | #import 5 | 6 | //! Project version number for Tailscale. 7 | FOUNDATION_EXPORT double TailscaleKitVersionNumber; 8 | 9 | //! Project version string for Tailscale. 10 | FOUNDATION_EXPORT const unsigned char TailscaleKitVersionString[]; 11 | 12 | 13 | // TODO: Is there away to avoid the header duplication here? 14 | // WARNING: Adding/changing the libtailscale functions must be replicated here 15 | #include 16 | 17 | #ifndef TAILSCALEKIT_H 18 | #define TAILSCALEKIT_H 19 | 20 | #ifdef __cplusplus 21 | extern "C" { 22 | #endif 23 | 24 | // 25 | // Use this library to compile Tailscale into your program and get 26 | // an entirely userspace IP address on a tailnet. 27 | // 28 | // From here you can listen for other programs on your tailnet dialing 29 | // you, or connect directly to other services. 30 | // 31 | 32 | // tailscale is a handle onto a Tailscale server. 33 | typedef int tailscale; 34 | 35 | // tailscale_new creates a tailscale server object. 36 | // 37 | // No network connection is initialized until tailscale_start is called. 38 | extern tailscale tailscale_new(); 39 | 40 | // tailscale_start connects the server to the tailnet. 41 | // 42 | // Calling this function is optional as it will be called by the first use 43 | // of tailscale_listen or tailscale_dial on a server. 44 | // 45 | // See also: tailscale_up. 46 | // 47 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 48 | extern int tailscale_start(tailscale sd); 49 | 50 | // tailscale_up connects the server to the tailnet and waits for it to be usable. 51 | // 52 | // To cancel an in-progress call to tailscale_up, use tailscale_close. 53 | // 54 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 55 | extern int tailscale_up(tailscale sd); 56 | 57 | // tailscale_close shuts down the server. 58 | // 59 | // Returns: 60 | // 0 - success 61 | // EBADF - sd is not a valid tailscale 62 | // -1 - other error, details printed to the tsnet logger 63 | extern int tailscale_close(tailscale sd); 64 | 65 | // The following set tailscale configuration options. 66 | // 67 | // Configure these options before any explicit or implicit call to tailscale_start. 68 | // 69 | // For details of each value see the godoc for the fields of tsnet.Server. 70 | // 71 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 72 | extern int tailscale_set_dir(tailscale sd, const char* dir); 73 | extern int tailscale_set_hostname(tailscale sd, const char* hostname); 74 | extern int tailscale_set_authkey(tailscale sd, const char* authkey); 75 | extern int tailscale_set_control_url(tailscale sd, const char* control_url); 76 | extern int tailscale_set_ephemeral(tailscale sd, int ephemeral); 77 | 78 | // tailscale_set_logfd instructs the tailscale instance to write logs to fd. 79 | // 80 | // An fd value of -1 means discard all logging. 81 | // 82 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 83 | extern int tailscale_set_logfd(tailscale sd, int fd); 84 | 85 | // A tailscale_conn is a connection to an address on the tailnet. 86 | // 87 | // It is a pipe(2) on which you can use read(2), write(2), and close(2). 88 | // For extra control over the connection, see the tailscale_conn_* functions. 89 | typedef int tailscale_conn; 90 | 91 | // Returns the IP addresses of the the Tailscale server as 92 | // a comma separated list. 93 | // 94 | // The provided buffer must be of sufficient size to hold the concatenated 95 | // IPs as strings. This is typically , but maybe empty, or 96 | // contain any number of ips. The caller is responsible for parsing 97 | // the output. You may assume the output is a list of well-formed IPs. 98 | // 99 | // Returns: 100 | // 0 - Success 101 | // EBADF - sd is not a valid tailscale, or l or conn are not valid listeneras or connections 102 | // ERANGE - insufficient storage for buf 103 | extern int tailscale_getips(tailscale sd, char* buf, size_t buflen); 104 | 105 | // tailscale_dial connects to the address on the tailnet. 106 | // 107 | // The newly allocated connection is written to conn_out. 108 | // 109 | // network is a NUL-terminated string of the form "tcp", "udp", etc. 110 | // addr is a NUL-terminated string of an IP address or domain name. 111 | // 112 | // It will start the server if it has not been started yet. 113 | // 114 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 115 | extern int tailscale_dial(tailscale sd, const char* network, const char* addr, tailscale_conn* conn_out); 116 | 117 | // A tailscale_listener is a socket on the tailnet listening for connections. 118 | // 119 | // It is much like allocating a system socket(2) and calling listen(2). 120 | // Accept connections with tailscale_accept and close the listener with close. 121 | // 122 | // Under the hood, a tailscale_listener is one half of a socketpair itself, 123 | // used to move the connection fd from Go to C. This means you can use epoll 124 | // or its equivalent on a tailscale_listener to know if there is a connection 125 | // read to accept. 126 | typedef int tailscale_listener; 127 | 128 | // tailscale_listen listens for a connection on the tailnet. 129 | // 130 | // It is the spiritual equivalent to listen(2). 131 | // The newly allocated listener is written to listener_out. 132 | // 133 | // network is a NUL-terminated string of the form "tcp", "udp", etc. 134 | // addr is a NUL-terminated string of an IP address or domain name. 135 | // 136 | // It will start the server if it has not been started yet. 137 | // 138 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 139 | extern int tailscale_listen(tailscale sd, const char* network, const char* addr, tailscale_listener* listener_out); 140 | 141 | // Returns the remote address for an incoming connection for a particular listener. The address (either ip4 or ip6) 142 | // will ge written to buf on on success. 143 | // Returns: 144 | // 0 - Success 145 | // EBADF - sd is not a valid tailscale, or l or conn are not valid listeneras or connections 146 | // ERANGE - insufficient storage for buf 147 | extern int tailscale_getremoteaddr(tailscale_listener l, tailscale_conn conn, char* buf, size_t buflen); 148 | 149 | 150 | // tailscale_accept accepts a connection on a tailscale_listener. 151 | // 152 | // It is the spiritual equivalent to accept(2). 153 | // 154 | // The newly allocated connection is written to conn_out. 155 | // 156 | // Returns: 157 | // 0 - success 158 | // EBADF - listener is not a valid tailscale 159 | // -1 - call tailscale_errmsg for details 160 | extern int tailscale_accept(tailscale_listener listener, tailscale_conn* conn_out); 161 | 162 | // tailscale_loopback starts a loopback address server. 163 | // 164 | // The server has multiple functions. 165 | // 166 | // It can be used as a SOCKS5 proxy onto the tailnet. 167 | // Authentication is required with the username "tsnet" and 168 | // the value of proxy_cred used as the password. 169 | // 170 | // The HTTP server also serves out the "LocalAPI" on /localapi. 171 | // As the LocalAPI is powerful, access to endpoints requires BOTH passing a 172 | // "Sec-Tailscale: localapi" HTTP header and passing local_api_cred as 173 | // the basic auth password. 174 | // 175 | // The pointers proxy_cred_out and local_api_cred_out must be non-NIL 176 | // and point to arrays that can hold 33 bytes. The first 32 bytes are 177 | // the credential and the final byte is a NUL terminator. 178 | // 179 | // If tailscale_loopback returns, then addr_our, proxy_cred_out, 180 | // and local_api_cred_out are all NUL-terminated. 181 | // 182 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 183 | extern int tailscale_loopback(tailscale sd, char* addr_out, size_t addrlen, char* proxy_cred_out, char* local_api_cred_out); 184 | 185 | // tailscale_errmsg writes the details of the last error to buf. 186 | // 187 | // After returning, buf is always NUL-terminated. 188 | // 189 | // Returns: 190 | // 0 - success 191 | // EBADF - sd is not a valid tailscale 192 | // ERANGE - insufficient storage for buf 193 | extern int tailscale_errmsg(tailscale sd, char* buf, size_t buflen); 194 | 195 | 196 | 197 | #ifdef __cplusplus 198 | } 199 | #endif 200 | 201 | #endif 202 | -------------------------------------------------------------------------------- /swift/TailscaleKit/TailscaleNode.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | public let kDefaultControlURL = "https://controlplane.tailscale.com" 5 | 6 | 7 | /// Configuration for a tailscale application node 8 | public struct Configuration: Sendable { 9 | public let hostName: String ///< The hostname of the node/application instance 10 | public let path: String 11 | public let authKey: String? ///< An auth key. Leave empty to use web auth 12 | public let controlURL: String ///< URL for Tailscale control 13 | public let ephemeral: Bool 14 | 15 | public init(hostName: String, 16 | path: String, 17 | authKey: String?, 18 | controlURL: String, 19 | ephemeral: Bool = false) 20 | { 21 | self.hostName = hostName 22 | self.path = path 23 | self.authKey = authKey 24 | self.controlURL = controlURL 25 | self.ephemeral = ephemeral 26 | } 27 | 28 | } 29 | 30 | /// The layer 3 protocol to use 31 | public enum NetProtocol: String { 32 | case tcp = "tcp" 33 | case udp = "udp" 34 | } 35 | 36 | public typealias IPAddresses = (ip4: String?, ip6: String?) 37 | 38 | /// TSInterface creates and manages a single userspace Tailscale application 39 | /// node. You may instantiate several "nodes" in a single application. Each 40 | /// will get a unique IP address on the Tailnet. 41 | /// 42 | /// The provided wrapper abstract away the C code and allow the writing of proper, 43 | /// compiler checked thread-safe Swift 6. 44 | public actor TailscaleNode { 45 | 46 | /// Handle to the underlying Tailscale server. Use this when instantiating 47 | /// new IncomingConnections or OutgoingConnections 48 | public let tailscale: TailscaleHandle? 49 | 50 | private let logger: LogSink? 51 | 52 | /// Instantiate a new TailscaleNode with the given configuration and 53 | /// and optional LogSink. If no LogSink is provided, logs will be 54 | /// discarded. 55 | /// 56 | /// @See tailscale_set_* in Tailscale.h 57 | /// @See tailscale_start in Tailscale.h 58 | /// 59 | /// @throws TailscaleError on failure 60 | public init(config: Configuration, logger: LogSink?) throws { 61 | self.logger = logger ?? BlackholeLogger() 62 | 63 | tailscale = tailscale_new() 64 | 65 | guard let tailscale else { 66 | throw TailscaleError.badInterfaceHandle 67 | } 68 | 69 | logger?.log("Tailscale starting: \(tailscale)") 70 | 71 | if let fd = logger?.logFileHandle { 72 | tailscale_set_logfd(tailscale, fd) 73 | } 74 | 75 | if let authKey = config.authKey { 76 | tailscale_set_authkey(tailscale, authKey) 77 | } 78 | 79 | tailscale_set_hostname(tailscale, config.hostName) 80 | tailscale_set_dir(tailscale, config.path) 81 | tailscale_set_control_url(tailscale, config.controlURL) 82 | tailscale_set_ephemeral(tailscale, config.ephemeral ? 1 : 0) 83 | 84 | let res = tailscale_start(tailscale) 85 | 86 | guard res == 0 else { 87 | throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage()) 88 | } 89 | 90 | logger?.log("Tailscale started... \(tailscale)") 91 | } 92 | 93 | deinit { 94 | if let tailscale { 95 | tailscale_close(tailscale) 96 | } 97 | } 98 | 99 | /// Closes/stops the Tailscale server 100 | /// 101 | /// @See tailscale_close in Tailscale.h 102 | /// 103 | /// @Throws TailscaleError on failure 104 | public func close() async throws { 105 | guard let tailscale else { 106 | throw TailscaleError.badInterfaceHandle 107 | } 108 | 109 | logger?.log("Closing Tailscale: \(tailscale)") 110 | let res = tailscale_close(tailscale) 111 | 112 | guard res == 0 else { 113 | throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage()) 114 | } 115 | logger?.log("Closed Tailscale:\(tailscale)") 116 | } 117 | 118 | /// Brings up the Tailscale server 119 | /// 120 | /// @See tailscale_up in Tailscale.h 121 | /// 122 | /// @throws TailscaleError on failure 123 | public func up() async throws { 124 | guard let tailscale else { 125 | throw TailscaleError.badInterfaceHandle 126 | } 127 | 128 | logger?.log("Bringing Tailscale up :\(tailscale)") 129 | let res = tailscale_up(tailscale) 130 | 131 | guard res == 0 else { 132 | throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage()) 133 | } 134 | logger?.log("Brought Tailscale up:\(tailscale)") 135 | } 136 | 137 | /// Tears down the Tailscale server. 138 | /// 139 | /// @See tailscale_down in Tailscale.h 140 | /// 141 | /// @throws TailscaleError on failure 142 | public func down() throws { 143 | guard let tailscale else { 144 | throw TailscaleError.badInterfaceHandle 145 | } 146 | logger?.log("Taking Tailscale down :\(tailscale)") 147 | 148 | let res = tailscale_up(tailscale) 149 | 150 | guard res == 0 else { 151 | throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage()) 152 | } 153 | logger?.log("Took Tailscale down:\(tailscale)") 154 | } 155 | 156 | /// Returns the addresses on the Tailscale server 157 | /// 158 | /// @See tailscale_getips in Tailscale.h 159 | /// 160 | /// @returns An ipV4 and ipV5 address tuple 161 | /// @throws TailscaleError on failure 162 | public func addrs() async throws -> IPAddresses { 163 | guard let tailscale else { 164 | throw TailscaleError.badInterfaceHandle 165 | } 166 | 167 | let buf = UnsafeMutablePointer.allocate(capacity: 128) 168 | defer { 169 | buf.deallocate() 170 | } 171 | let res = tailscale_getips(tailscale, buf, 128) 172 | 173 | guard res == 0 else { 174 | throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage()) 175 | } 176 | 177 | let ipList = String(cString: buf) 178 | return ipList.toIPPair() 179 | } 180 | 181 | public struct LoopbackConfig: Sendable { 182 | public let address: String 183 | public let proxyCredential: String 184 | public let localAPIKey: String 185 | 186 | public var ip: String? { 187 | let parts = address.split(separator: ":") 188 | let addr = parts.first 189 | guard parts.count == 2, let addr else { 190 | return nil 191 | } 192 | return String(addr) 193 | } 194 | 195 | public var port: Int? { 196 | let parts = address.split(separator: ":") 197 | let port = parts.last 198 | guard parts.count == 2, let port else { 199 | return nil 200 | } 201 | return Int(port) 202 | } 203 | } 204 | 205 | private var loopbackConfig: LoopbackConfig? 206 | 207 | /// Starts and returns the address and credentials of a SOCKS5 proxy which can also 208 | /// be used to query the localAPI 209 | public func loopback() throws -> LoopbackConfig { 210 | guard let tailscale else { 211 | throw TailscaleError.badInterfaceHandle 212 | } 213 | 214 | if let loopbackConfig = loopbackConfig { 215 | return loopbackConfig 216 | } 217 | 218 | let addrBuf = UnsafeMutablePointer.allocate(capacity: 64) 219 | let proxyCredBuf = UnsafeMutablePointer.allocate(capacity: 33) 220 | let apiCredBuf = UnsafeMutablePointer.allocate(capacity: 33) 221 | defer { 222 | addrBuf.deallocate() 223 | proxyCredBuf.deallocate() 224 | apiCredBuf.deallocate() 225 | } 226 | 227 | let res = tailscale_loopback(tailscale, addrBuf, 64, proxyCredBuf, apiCredBuf) 228 | guard res == 0 else { 229 | throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage()) 230 | } 231 | 232 | loopbackConfig = LoopbackConfig(address: String(cString: addrBuf), 233 | proxyCredential: String(cString: proxyCredBuf), 234 | localAPIKey: String(cString: apiCredBuf)) 235 | return loopbackConfig! 236 | 237 | } 238 | } 239 | 240 | // MARK: - IP String list to IPAddresses tuple 241 | 242 | enum IPAddrType { 243 | case v4 244 | case v6 245 | case none 246 | } 247 | 248 | extension String { 249 | // tailscale.go sends us the tailnetIPs as a comma separated list. This will 250 | // turn them into an IPAddresses tuple 251 | func toIPPair() -> IPAddresses { 252 | let ips = self.split(separator: ",").map { String($0) } 253 | var result: IPAddresses = (nil, nil) 254 | for ip in ips { 255 | let type = ip.tsNetIPAddrType() 256 | switch type { 257 | case .v4: 258 | result.ip4 = ip 259 | case .v6: 260 | result.ip6 = ip 261 | case .none: 262 | break 263 | } 264 | } 265 | return result 266 | } 267 | 268 | 269 | // This can be naive since the backend is only vending well 270 | // formed IPs to us. 271 | func tsNetIPAddrType() -> IPAddrType { 272 | if self.contains(".") { 273 | return .v4 274 | } else if self.contains(":") { 275 | return .v6 276 | } 277 | return .none 278 | } 279 | } 280 | 281 | 282 | -------------------------------------------------------------------------------- /swift/TailscaleKit/URLSession+Tailscale.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | #if os(iOS) 5 | import UIKit 6 | #endif 7 | 8 | public extension URLSessionConfiguration { 9 | 10 | /// Adds the a connectionProxyDictionary to a URLSessionConfiguration to 11 | /// proxy all requests through the given TailscaleNode. 12 | /// 13 | /// This can also be use to make requests to LocalAPI. See LocalAPIClient 14 | @discardableResult 15 | func proxyVia(_ node: TailscaleNode) async throws -> TailscaleNode.LoopbackConfig { 16 | let proxyConfig = try await node.loopback() 17 | 18 | guard let ip = proxyConfig.ip, let port = proxyConfig.port else { 19 | throw TailscaleError.invalidProxyAddress 20 | } 21 | 22 | 23 | let config: [CFString: Any] = [ 24 | kCFProxyTypeKey: kCFProxyTypeSOCKS, 25 | kCFProxyUsernameKey: "tsnet", 26 | kCFProxyPasswordKey: proxyConfig.proxyCredential, 27 | kCFProxyHostNameKey: ip, 28 | kCFProxyPortNumberKey: port 29 | ] 30 | 31 | self.connectionProxyDictionary = config 32 | 33 | return proxyConfig 34 | } 35 | 36 | static func tailscaleSession(_ node: TailscaleNode) async throws -> (URLSessionConfiguration, TailscaleNode.LoopbackConfig) { 37 | let session = URLSessionConfiguration.default 38 | let config = try await session.proxyVia(node) 39 | return (session, config) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /swift/TailscaleKitXCTests/TailscaleKitTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | import XCTest 5 | @testable import TailscaleKit 6 | 7 | final class TailscaleKitTests: XCTestCase { 8 | var controlURL: String = "" 9 | 10 | override func setUp() async throws { 11 | if controlURL == "" { 12 | var buf = [CChar](repeating:0, count: 1024) 13 | let res = buf.withUnsafeMutableBufferPointer { ptr in 14 | return run_control(ptr.baseAddress!, 1024) 15 | } 16 | let len = buf.firstIndex(where: { $0 == 0 }) ?? 0 17 | let str = buf[0.. Configuration { 132 | let temp = getDocumentDirectoryPath().absoluteString + "tailscale\(hostCount)" 133 | hostCount += 1 134 | return Configuration( 135 | hostName: "testHost-\(hostCount)", 136 | path: temp, 137 | authKey: nil, 138 | controlURL: controlURL, 139 | ephemeral: false) 140 | } 141 | 142 | 143 | /// Tests that we can fetch a URL via our proxy (though this isn't a URL 144 | /// on the tailnet...) 145 | func testProxy() async throws { 146 | let config = mockConfig() 147 | let logger = BlackholeLogger() 148 | 149 | do { 150 | let ts1 = try TailscaleNode(config: config, logger: logger) 151 | try await ts1.up() 152 | 153 | let (sessionConfig, _) = try await URLSessionConfiguration.tailscaleSession(ts1) 154 | let session = URLSession(configuration: sessionConfig) 155 | 156 | let url = URL(string: "https://tailscale.com")! 157 | let req = URLRequest(url: url) 158 | let (data, _) = try await session.data(for: req) 159 | 160 | print("Got proxied data \(data.count)") 161 | XCTAssert(data.count > 0) 162 | } 163 | } 164 | 165 | /// Tests that localAPI is functional 166 | func testStatus() async throws { 167 | let config = mockConfig() 168 | let logger = BlackholeLogger() 169 | 170 | do { 171 | let ts1 = try TailscaleNode(config: config, logger: logger) 172 | try await ts1.up() 173 | 174 | // The local node should be running and online 175 | let api = LocalAPIClient(localNode: ts1, logger: logger) 176 | let status = try await api.backendStatus() 177 | XCTAssertEqual(status.BackendState, "Running") 178 | 179 | let peerStatus = status.SelfStatus! 180 | XCTAssertTrue(peerStatus.Online) 181 | } catch { 182 | XCTFail(error.localizedDescription) 183 | } 184 | } 185 | } 186 | 187 | 188 | func getDocumentDirectoryPath() -> URL { 189 | let arrayPaths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) 190 | let docDirectoryPath = arrayPaths[0] 191 | return docDirectoryPath 192 | } 193 | -------------------------------------------------------------------------------- /swift/TailscaleKitXCTests/Test-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | #include 5 | 6 | // External definitions for libtstestcontrol 7 | 8 | // TODO: Is there away to avoid the header duplication here? 9 | // WARNING: Adding/changing the libtstestcontrol functions must be replicated here 10 | 11 | // Runs a new control. Returns the URL in the buffer 12 | extern int run_control(char* buf, size_t buflen); 13 | 14 | // Stops the running control 15 | extern void stop_control(); 16 | -------------------------------------------------------------------------------- /swift/script/clangwrap-ios-sim-arm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | SDK=iphonesimulator 4 | PLATFORM=ios-simulator 5 | 6 | CLANGARCH=arm64 7 | 8 | SDK_PATH=`xcrun --sdk $SDK --show-sdk-path` 9 | 10 | # cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang. 11 | CLANG=`xcrun --sdk $SDK --find clang` 12 | 13 | exec "$CLANG" -arch $CLANGARCH -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=12.0 "$@" 14 | -------------------------------------------------------------------------------- /swift/script/clangwrap-ios-sim-x86.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | SDK=iphonesimulator 4 | PLATFORM=ios-simulator 5 | 6 | CLANGARCH=x86_64 7 | 8 | SDK_PATH=`xcrun --sdk $SDK --show-sdk-path` 9 | 10 | # cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang. 11 | CLANG=`xcrun --sdk $SDK --find clang` 12 | 13 | exec "$CLANG" -arch $CLANGARCH -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=12.0 "$@" 14 | -------------------------------------------------------------------------------- /swift/script/clangwrap-ios.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | 4 | SDK=iphoneos 5 | PLATFORM=ios 6 | 7 | CLANGARCH="arm64" 8 | 9 | SDK_PATH=`xcrun --sdk $SDK --show-sdk-path` 10 | 11 | # cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang. 12 | CLANG=`xcrun --sdk $SDK --find clang` 13 | 14 | exec "$CLANG" -arch $CLANGARCH -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=12.0 "$@" 15 | -------------------------------------------------------------------------------- /tailscale.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | #include "tailscale.h" 5 | #include 6 | #include 7 | #include 8 | 9 | // Functions exported by Go. 10 | extern int TsnetNewServer(); 11 | extern int TsnetStart(int sd); 12 | extern int TsnetUp(int sd); 13 | extern int TsnetClose(int sd); 14 | extern int TsnetErrmsg(int sd, char* buf, size_t buflen); 15 | extern int TsnetDial(int sd, char* net, char* addr, int* connOut); 16 | extern int TsnetSetDir(int sd, char* str); 17 | extern int TsnetSetHostname(int sd, char* str); 18 | extern int TsnetSetAuthKey(int sd, char* str); 19 | extern int TsnetSetControlURL(int sd, char* str); 20 | extern int TsnetSetEphemeral(int sd, int ephemeral); 21 | extern int TsnetSetLogFD(int sd, int fd); 22 | extern int TsnetGetIps(int sd, char *buf, size_t buflen); 23 | extern int TsnetGetRemoteAddr(int listener, int conn, char *buf, size_t buflen); 24 | extern int TsnetListen(int sd, char* net, char* addr, int* listenerOut); 25 | extern int TsnetLoopback(int sd, char* addrOut, size_t addrLen, char* proxyOut, char* localOut); 26 | extern int TsnetEnableFunnelToLocalhostPlaintextHttp1(int sd, int localhostPort); 27 | 28 | tailscale tailscale_new() { 29 | return TsnetNewServer(); 30 | } 31 | 32 | int tailscale_start(tailscale sd) { 33 | return TsnetStart(sd); 34 | } 35 | 36 | int tailscale_up(tailscale sd) { 37 | return TsnetUp(sd); 38 | } 39 | 40 | int tailscale_close(tailscale sd) { 41 | return TsnetClose(sd); 42 | } 43 | 44 | int tailscale_dial(tailscale sd, const char* network, const char* addr, tailscale_conn* conn_out) { 45 | return TsnetDial(sd, (char*)network, (char*)addr, (int*)conn_out); 46 | } 47 | 48 | int tailscale_listen(tailscale sd, const char* network, const char* addr, tailscale_listener* listener_out) { 49 | return TsnetListen(sd, (char*)network, (char*)addr, (int*)listener_out); 50 | } 51 | 52 | int tailscale_accept(tailscale_listener ld, tailscale_conn* conn_out) { 53 | struct msghdr msg = {0}; 54 | 55 | char mbuf[256]; 56 | struct iovec io = { .iov_base = mbuf, .iov_len = sizeof(mbuf) }; 57 | msg.msg_iov = &io; 58 | msg.msg_iovlen = 1; 59 | 60 | char cbuf[256]; 61 | msg.msg_control = cbuf; 62 | msg.msg_controllen = sizeof(cbuf); 63 | 64 | if (recvmsg(ld, &msg, 0) == -1) { 65 | return -1; 66 | } 67 | 68 | struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); 69 | unsigned char* data = CMSG_DATA(cmsg); 70 | 71 | int fd = *(int*)data; 72 | *conn_out = fd; 73 | return 0; 74 | } 75 | 76 | int tailscale_getremoteaddr(tailscale_listener l, tailscale_conn conn, char* buf, size_t buflen) { 77 | return TsnetGetRemoteAddr(l, conn, buf, buflen); 78 | } 79 | 80 | int tailscale_getips(tailscale sd, char* buf, size_t buflen) { 81 | return TsnetGetIps(sd, buf, buflen); 82 | } 83 | 84 | int tailscale_set_dir(tailscale sd, const char* dir) { 85 | return TsnetSetDir(sd, (char*)dir); 86 | } 87 | int tailscale_set_hostname(tailscale sd, const char* hostname) { 88 | return TsnetSetHostname(sd, (char*)hostname); 89 | } 90 | int tailscale_set_authkey(tailscale sd, const char* authkey) { 91 | return TsnetSetAuthKey(sd, (char*)authkey); 92 | } 93 | int tailscale_set_control_url(tailscale sd, const char* control_url) { 94 | return TsnetSetControlURL(sd, (char*)control_url); 95 | } 96 | int tailscale_set_ephemeral(tailscale sd, int ephemeral) { 97 | return TsnetSetEphemeral(sd, ephemeral); 98 | } 99 | int tailscale_set_logfd(tailscale sd, int fd) { 100 | return TsnetSetLogFD(sd, fd); 101 | } 102 | 103 | int tailscale_loopback(tailscale sd, char* addr_out, size_t addrlen, char* proxy_cred_out, char* local_api_cred_out) { 104 | return TsnetLoopback(sd, addr_out, addrlen, proxy_cred_out, local_api_cred_out); 105 | } 106 | 107 | int tailscale_errmsg(tailscale sd, char* buf, size_t buflen) { 108 | return TsnetErrmsg(sd, buf, buflen); 109 | } 110 | 111 | int tailscale_enable_funnel_to_localhost_plaintext_http1(tailscale sd, int localhostPort) { 112 | return TsnetEnableFunnelToLocalhostPlaintextHttp1(sd, localhostPort); 113 | } 114 | -------------------------------------------------------------------------------- /tailscale.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // A Go c-archive of the tsnet package. See tailscale.h for details. 5 | package main 6 | 7 | //#include "errno.h" 8 | import "C" 9 | 10 | import ( 11 | "context" 12 | "fmt" 13 | "io" 14 | "net" 15 | "os" 16 | "regexp" 17 | "strconv" 18 | "strings" 19 | "sync" 20 | "syscall" 21 | "unsafe" 22 | 23 | "tailscale.com/hostinfo" 24 | "tailscale.com/ipn" 25 | "tailscale.com/tsnet" 26 | "tailscale.com/types/logger" 27 | ) 28 | 29 | func main() {} 30 | 31 | // servers tracks all the allocated *tsnet.Server objects. 32 | var servers struct { 33 | mu sync.Mutex 34 | next C.int 35 | m map[C.int]*server 36 | } 37 | 38 | type server struct { 39 | s *tsnet.Server 40 | lastErr string 41 | } 42 | 43 | func getServer(sd C.int) (*server, error) { 44 | servers.mu.Lock() 45 | defer servers.mu.Unlock() 46 | 47 | s := servers.m[sd] 48 | if s == nil { 49 | return nil, fmt.Errorf("tsnetc: unknown server descriptors %d (of %d servers)", sd, len(servers.m)) 50 | } 51 | return s, nil 52 | } 53 | 54 | // listeners tracks all the tsnet_listener objects allocated via tsnet_listen. 55 | var listeners struct { 56 | mu sync.Mutex 57 | m map[C.int]*listener 58 | } 59 | 60 | type listener struct { 61 | s *server 62 | ln net.Listener 63 | fd int // go side fd of socketpair sent to C 64 | mu sync.Mutex 65 | m map[C.int]net.Addr //maps fds to remote addresses for lookup 66 | } 67 | 68 | // conns tracks all the pipe(2)s allocated via tsnet_dial. 69 | var conns struct { 70 | mu sync.Mutex 71 | m map[C.int]*conn // keyed by the FD given to C (w) 72 | } 73 | 74 | type conn struct { 75 | s *tsnet.Server 76 | c net.Conn 77 | r *os.File // r is the local socket to the C client 78 | } 79 | 80 | func (s *server) recErr(err error) C.int { 81 | if err == nil { 82 | s.lastErr = "" 83 | return 0 84 | } 85 | s.lastErr = err.Error() 86 | return -1 87 | } 88 | 89 | //export TsnetNewServer 90 | func TsnetNewServer() C.int { 91 | servers.mu.Lock() 92 | defer servers.mu.Unlock() 93 | 94 | if servers.m == nil { 95 | servers.m = map[C.int]*server{} 96 | hostinfo.SetApp("libtailscale") 97 | } 98 | if servers.next == 0 { 99 | servers.next = 42<<16 + 1 100 | } 101 | sd := servers.next 102 | servers.next++ 103 | s := &server{s: &tsnet.Server{}} 104 | servers.m[sd] = s 105 | return (C.int)(sd) 106 | } 107 | 108 | //export TsnetStart 109 | func TsnetStart(sd C.int) C.int { 110 | s, err := getServer(sd) 111 | if err != nil { 112 | return s.recErr(err) 113 | } 114 | return s.recErr(s.s.Start()) 115 | } 116 | 117 | //export TsnetUp 118 | func TsnetUp(sd C.int) C.int { 119 | s, err := getServer(sd) 120 | if err != nil { 121 | return s.recErr(err) 122 | } 123 | _, err = s.s.Up(context.Background()) // cancellation is via TsnetClose 124 | return s.recErr(err) 125 | } 126 | 127 | //export TsnetClose 128 | func TsnetClose(sd C.int) C.int { 129 | servers.mu.Lock() 130 | s := servers.m[sd] 131 | if s != nil { 132 | delete(servers.m, sd) 133 | } 134 | servers.mu.Unlock() 135 | 136 | if s == nil { 137 | return C.EBADF 138 | } 139 | 140 | // TODO: cancel Up 141 | // TODO: close related listeners / conns. 142 | if err := s.s.Close(); err != nil { 143 | s.s.Logf("tailscale_close: failed with %v", err) 144 | return -1 145 | } 146 | 147 | return 0 148 | } 149 | 150 | //export TsnetGetIps 151 | func TsnetGetIps(sd C.int, buf *C.char, buflen C.size_t) C.int { 152 | if buf == nil { 153 | panic("errmsg passed nil buf") 154 | } else if buflen == 0 { 155 | panic("errmsg passed buflen of 0") 156 | } 157 | 158 | servers.mu.Lock() 159 | s := servers.m[sd] 160 | servers.mu.Unlock() 161 | 162 | out := unsafe.Slice((*byte)(unsafe.Pointer(buf)), buflen) 163 | 164 | if s == nil { 165 | out[0] = '\x00' 166 | return C.EBADF 167 | } 168 | 169 | ip4, ip6 := s.s.TailscaleIPs() 170 | joined := strings.Join([]string{ip4.String(), ip6.String()}, ",") 171 | n := copy(out, joined) 172 | if len(out) < len(joined)-1 { 173 | out[len(out)-1] = '\x00' // always NUL-terminate 174 | return C.ERANGE 175 | } 176 | out[n] = '\x00' 177 | return 0 178 | } 179 | 180 | //export TsnetErrmsg 181 | func TsnetErrmsg(sd C.int, buf *C.char, buflen C.size_t) C.int { 182 | if buf == nil { 183 | panic("errmsg passed nil buf") 184 | } else if buflen == 0 { 185 | panic("errmsg passed buflen of 0") 186 | } 187 | 188 | servers.mu.Lock() 189 | s := servers.m[sd] 190 | servers.mu.Unlock() 191 | 192 | out := unsafe.Slice((*byte)(unsafe.Pointer(buf)), buflen) 193 | if s == nil { 194 | out[0] = '\x00' 195 | return C.EBADF 196 | } 197 | n := copy(out, s.lastErr) 198 | if len(out) < len(s.lastErr)-1 { 199 | out[len(out)-1] = '\x00' // always NUL-terminate 200 | return C.ERANGE 201 | } 202 | out[n] = '\x00' 203 | return 0 204 | } 205 | 206 | //export TsnetListen 207 | func TsnetListen(sd C.int, network, addr *C.char, listenerOut *C.int) C.int { 208 | s, err := getServer(sd) 209 | if err != nil { 210 | return s.recErr(err) 211 | } 212 | 213 | ln, err := s.s.Listen(C.GoString(network), C.GoString(addr)) 214 | if err != nil { 215 | return s.recErr(err) 216 | } 217 | 218 | // The tailscale_listener we return to C is one side of a socketpair(2). 219 | // We do this so we can proactively call ln.Accept in a goroutine and 220 | // feed an fd for the connection through the listener. This lets C use 221 | // epoll on the tailscale_listener to know if it should call 222 | // tailscale_accept, which avoids a blocking call on the far side. 223 | fds, err := syscall.Socketpair(syscall.AF_LOCAL, syscall.SOCK_STREAM, 0) 224 | if err != nil { 225 | return s.recErr(err) 226 | } 227 | sp := fds[1] 228 | fdC := C.int(fds[0]) 229 | 230 | listeners.mu.Lock() 231 | if listeners.m == nil { 232 | listeners.m = map[C.int]*listener{} 233 | } 234 | listener := &listener{s: s, ln: ln, fd: sp, m: map[C.int]net.Addr{}} 235 | listeners.m[fdC] = listener 236 | listeners.mu.Unlock() 237 | 238 | cleanup := func() { 239 | // If fdC is closed on the C side, then we end up calling 240 | // into cleanup twice. Be careful to avoid syscall.Close 241 | // twice as the FD may have been reallocated. 242 | listeners.mu.Lock() 243 | if tsLn, ok := listeners.m[fdC]; ok && tsLn.ln == ln { 244 | delete(listeners.m, fdC) 245 | syscall.Close(sp) 246 | } 247 | listeners.mu.Unlock() 248 | 249 | ln.Close() 250 | } 251 | go func() { 252 | // fdC is never written to, so trying to read from sp blocks 253 | // until fdC is closed. We use this as a signal that C is 254 | // done with the listener, and we can tear it down. 255 | // 256 | // TODO: would using os.NewFile avoid a locked up thread? 257 | var buf [256]byte 258 | syscall.Read(sp, buf[:]) 259 | cleanup() 260 | }() 261 | go func() { 262 | defer cleanup() 263 | for { 264 | netConn, err := ln.Accept() 265 | if err != nil { 266 | return 267 | } 268 | var connFd C.int 269 | if err := newConn(s, netConn, &connFd); err != nil { 270 | if s.s.Logf != nil { 271 | s.s.Logf("libtailscale.accept: newConn: %v", err) 272 | } 273 | netConn.Close() 274 | continue 275 | } 276 | rights := syscall.UnixRights(int(connFd)) 277 | err = syscall.Sendmsg(sp, nil, rights, nil, 0) 278 | if err != nil { 279 | // We handle sp being closed in the read goroutine above. 280 | if s.s.Logf != nil { 281 | s.s.Logf("libtailscale.accept: sendmsg failed: %v", err) 282 | } 283 | netConn.Close() 284 | // fallthrough to close connFd, then continue Accept()ing 285 | } 286 | 287 | // map the connection to the remote address 288 | listener.mu.Lock() 289 | listener.m[connFd] = netConn.RemoteAddr() 290 | listener.mu.Unlock() 291 | 292 | syscall.Close(int(connFd)) // now owned by recvmsg 293 | } 294 | }() 295 | 296 | *listenerOut = fdC 297 | return 0 298 | } 299 | 300 | func newConn(s *server, netConn net.Conn, connOut *C.int) error { 301 | fds, err := syscall.Socketpair(syscall.AF_LOCAL, syscall.SOCK_STREAM, 0) 302 | if err != nil { 303 | return err 304 | } 305 | r := os.NewFile(uintptr(fds[1]), "socketpair-r") 306 | c := &conn{s: s.s, c: netConn, r: r} 307 | fdC := C.int(fds[0]) 308 | 309 | conns.mu.Lock() 310 | if conns.m == nil { 311 | conns.m = make(map[C.int]*conn) 312 | } 313 | conns.m[fdC] = c 314 | conns.mu.Unlock() 315 | 316 | connCleanup := func() { 317 | var inCleanup bool 318 | conns.mu.Lock() 319 | if tsConn, ok := conns.m[fdC]; ok && tsConn.c == netConn { 320 | delete(conns.m, fdC) 321 | inCleanup = true 322 | } 323 | conns.mu.Unlock() 324 | 325 | if !inCleanup { 326 | return 327 | } 328 | 329 | r.Close() 330 | netConn.Close() 331 | } 332 | go func() { 333 | defer connCleanup() 334 | var b [1 << 16]byte 335 | io.CopyBuffer(r, netConn, b[:]) 336 | syscall.Shutdown(int(r.Fd()), syscall.SHUT_WR) 337 | if cr, ok := netConn.(interface{ CloseRead() error }); ok { 338 | cr.CloseRead() 339 | } 340 | }() 341 | go func() { 342 | defer connCleanup() 343 | var b [1 << 16]byte 344 | io.CopyBuffer(netConn, r, b[:]) 345 | syscall.Shutdown(int(r.Fd()), syscall.SHUT_RD) 346 | if cw, ok := netConn.(interface{ CloseWrite() error }); ok { 347 | cw.CloseWrite() 348 | } 349 | }() 350 | 351 | *connOut = fdC 352 | return nil 353 | } 354 | 355 | //export TsnetGetRemoteAddr 356 | func TsnetGetRemoteAddr(listener C.int, conn C.int, buf *C.char, buflen C.size_t) C.int { 357 | if buf == nil { 358 | panic("errmsg passed nil buf") 359 | } else if buflen == 0 { 360 | panic("errmsg passed buflen of 0") 361 | } 362 | out := unsafe.Slice((*byte)(unsafe.Pointer(buf)), buflen) 363 | 364 | listeners.mu.Lock() 365 | defer listeners.mu.Unlock() 366 | l := listeners.m[listener] 367 | if l == nil { 368 | out[0] = '\x00' 369 | return C.EBADF 370 | } 371 | 372 | l.mu.Lock() 373 | defer l.mu.Unlock() 374 | addr, ok := l.m[conn] 375 | if !ok { 376 | out[0] = '\x00' 377 | return C.EBADF 378 | } 379 | 380 | ip := extractIP(addr.String()) 381 | 382 | n := copy(out, ip) 383 | if len(out) < len(ip)-1 { 384 | out[len(out)-1] = '\x00' // always NUL-terminate 385 | return C.ERANGE 386 | } 387 | out[n] = '\x00' 388 | return 0 389 | } 390 | 391 | // Strips the port from connection IPs 392 | func extractIP(ipWithPort string) string { 393 | re := regexp.MustCompile(`(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|\[([0-9a-fA-F:]+)\]`) 394 | match := re.FindString(ipWithPort) 395 | return match 396 | } 397 | 398 | //export TsnetDial 399 | func TsnetDial(sd C.int, network, addr *C.char, connOut *C.int) C.int { 400 | s, err := getServer(sd) 401 | if err != nil { 402 | return s.recErr(err) 403 | } 404 | netConn, err := s.s.Dial(context.Background(), C.GoString(network), C.GoString(addr)) 405 | if err != nil { 406 | return s.recErr(err) 407 | } 408 | if newConn(s, netConn, connOut); err != nil { 409 | return s.recErr(err) 410 | } 411 | return 0 412 | } 413 | 414 | //export TsnetSetDir 415 | func TsnetSetDir(sd C.int, str *C.char) C.int { 416 | s, err := getServer(sd) 417 | if err != nil { 418 | return s.recErr(err) 419 | } 420 | s.s.Dir = C.GoString(str) 421 | return 0 422 | } 423 | 424 | //export TsnetSetHostname 425 | func TsnetSetHostname(sd C.int, str *C.char) C.int { 426 | s, err := getServer(sd) 427 | if err != nil { 428 | return s.recErr(err) 429 | } 430 | s.s.Hostname = C.GoString(str) 431 | return 0 432 | } 433 | 434 | //export TsnetSetAuthKey 435 | func TsnetSetAuthKey(sd C.int, str *C.char) C.int { 436 | s, err := getServer(sd) 437 | if err != nil { 438 | return s.recErr(err) 439 | } 440 | s.s.AuthKey = C.GoString(str) 441 | return 0 442 | } 443 | 444 | //export TsnetSetControlURL 445 | func TsnetSetControlURL(sd C.int, str *C.char) C.int { 446 | s, err := getServer(sd) 447 | if err != nil { 448 | return s.recErr(err) 449 | } 450 | s.s.ControlURL = C.GoString(str) 451 | return 0 452 | } 453 | 454 | //export TsnetSetEphemeral 455 | func TsnetSetEphemeral(sd C.int, e int) C.int { 456 | s, err := getServer(sd) 457 | if err != nil { 458 | return s.recErr(err) 459 | } 460 | if e == 0 { 461 | s.s.Ephemeral = false 462 | } else { 463 | s.s.Ephemeral = true 464 | } 465 | return 0 466 | } 467 | 468 | //export TsnetSetLogFD 469 | func TsnetSetLogFD(sd, fd C.int) C.int { 470 | s, err := getServer(sd) 471 | if err != nil { 472 | return s.recErr(err) 473 | } 474 | if fd == -1 { 475 | s.s.Logf = logger.Discard 476 | return 0 477 | } 478 | f := os.NewFile(uintptr(fd), "logfd") 479 | s.s.Logf = func(format string, args ...any) { 480 | fmt.Fprintf(f, format, args...) 481 | fmt.Fprintf(f, "\n") 482 | } 483 | return 0 484 | } 485 | 486 | //export TsnetLoopback 487 | func TsnetLoopback(sd C.int, addrOut *C.char, addrLen C.size_t, proxyOut *C.char, localOut *C.char) C.int { 488 | // Panic here to ensure we always leave the out values NUL-terminated. 489 | if addrOut == nil { 490 | panic("loopback_api passed nil addr_out") 491 | } else if addrLen == 0 { 492 | panic("loopback_api passed addrlen of 0") 493 | } else if proxyOut == nil { 494 | panic("loopback_api passed nil proxy_cred_out") 495 | } else if localOut == nil { 496 | panic("loopback_api passed nil local_api_cred_out") 497 | } 498 | 499 | // Start out NUL-termianted to cover error conditions. 500 | *addrOut = '\x00' 501 | *localOut = '\x00' 502 | *proxyOut = '\x00' 503 | 504 | s, err := getServer(sd) 505 | if err != nil { 506 | return s.recErr(err) 507 | } 508 | addr, proxyCred, localAPICred, err := s.s.Loopback() 509 | if err != nil { 510 | return s.recErr(err) 511 | } 512 | if len(proxyCred) != 32 { 513 | return s.recErr(fmt.Errorf("libtailscale: len(proxyCred)=%d, want 32", len(proxyCred))) 514 | } 515 | if len(localAPICred) != 32 { 516 | return s.recErr(fmt.Errorf("libtailscale: len(localAPICred)=%d, want 32", len(localAPICred))) 517 | } 518 | if len(addr)+1 > int(addrLen) { 519 | return s.recErr(fmt.Errorf("libtailscale: loopback addr of %d bytes is too long for addrlen %d", len(addr), addrLen)) 520 | } 521 | out := unsafe.Slice((*byte)(unsafe.Pointer(addrOut)), addrLen) 522 | n := copy(out, addr) 523 | out[n] = '\x00' 524 | 525 | // proxyOut and localOut are non-nil and 33 bytes long because 526 | // they are defined in C as char cred_out[static 33]. 527 | out = unsafe.Slice((*byte)(unsafe.Pointer(proxyOut)), 33) 528 | copy(out, proxyCred) 529 | out[32] = '\x00' 530 | out = unsafe.Slice((*byte)(unsafe.Pointer(localOut)), 33) 531 | copy(out, localAPICred) 532 | out[32] = '\x00' 533 | 534 | return 0 535 | } 536 | 537 | //export TsnetEnableFunnelToLocalhostPlaintextHttp1 538 | func TsnetEnableFunnelToLocalhostPlaintextHttp1(sd C.int, localhostPort C.int) C.int { 539 | s, err := getServer(sd) 540 | if err != nil { 541 | return s.recErr(err) 542 | } 543 | 544 | ctx := context.Background() 545 | lc, err := s.s.LocalClient() 546 | if err != nil { 547 | return s.recErr(err) 548 | } 549 | 550 | st, err := lc.StatusWithoutPeers(ctx) 551 | if err != nil { 552 | return s.recErr(err) 553 | } 554 | domain := st.CertDomains[0] 555 | 556 | hp := ipn.HostPort(net.JoinHostPort(domain, strconv.Itoa(443))) 557 | tcpForward := fmt.Sprintf("127.0.0.1:%d", localhostPort) 558 | sc := &ipn.ServeConfig{ 559 | TCP: map[uint16]*ipn.TCPPortHandler{ 560 | 443: { 561 | TCPForward: tcpForward, 562 | TerminateTLS: domain, 563 | }, 564 | }, 565 | AllowFunnel: map[ipn.HostPort]bool{ 566 | hp: true, 567 | }, 568 | } 569 | 570 | lc.SetServeConfig(ctx, sc) 571 | if !sc.AllowFunnel[hp] { 572 | return s.recErr(fmt.Errorf("libtailscale: failed to enable funnel")) 573 | } 574 | 575 | return 0 576 | } 577 | -------------------------------------------------------------------------------- /tailscale.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // 5 | // Tailscale C library. 6 | // 7 | // Use this library to compile Tailscale into your program and get 8 | // an entirely userspace IP address on a tailnet. 9 | // 10 | // From here you can listen for other programs on your tailnet dialing 11 | // you, or connect directly to other services. 12 | // 13 | 14 | 15 | #include 16 | 17 | #ifndef TAILSCALE_H 18 | #define TAILSCALE_H 19 | 20 | #ifdef __cplusplus 21 | extern "C" { 22 | #endif 23 | 24 | 25 | // tailscale is a handle onto a Tailscale server. 26 | typedef int tailscale; 27 | 28 | // tailscale_new creates a tailscale server object. 29 | // 30 | // No network connection is initialized until tailscale_start is called. 31 | extern tailscale tailscale_new(); 32 | 33 | // tailscale_start connects the server to the tailnet. 34 | // 35 | // Calling this function is optional as it will be called by the first use 36 | // of tailscale_listen or tailscale_dial on a server. 37 | // 38 | // See also: tailscale_up. 39 | // 40 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 41 | extern int tailscale_start(tailscale sd); 42 | 43 | // tailscale_up connects the server to the tailnet and waits for it to be usable. 44 | // 45 | // To cancel an in-progress call to tailscale_up, use tailscale_close. 46 | // 47 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 48 | extern int tailscale_up(tailscale sd); 49 | 50 | // tailscale_close shuts down the server. 51 | // 52 | // Returns: 53 | // 0 - success 54 | // EBADF - sd is not a valid tailscale 55 | // -1 - other error, details printed to the tsnet logger 56 | extern int tailscale_close(tailscale sd); 57 | 58 | // The following set tailscale configuration options. 59 | // 60 | // Configure these options before any explicit or implicit call to tailscale_start. 61 | // 62 | // For details of each value see the godoc for the fields of tsnet.Server. 63 | // 64 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 65 | extern int tailscale_set_dir(tailscale sd, const char* dir); 66 | extern int tailscale_set_hostname(tailscale sd, const char* hostname); 67 | extern int tailscale_set_authkey(tailscale sd, const char* authkey); 68 | extern int tailscale_set_control_url(tailscale sd, const char* control_url); 69 | extern int tailscale_set_ephemeral(tailscale sd, int ephemeral); 70 | 71 | // tailscale_set_logfd instructs the tailscale instance to write logs to fd. 72 | // 73 | // An fd value of -1 means discard all logging. 74 | // 75 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 76 | extern int tailscale_set_logfd(tailscale sd, int fd); 77 | 78 | // A tailscale_conn is a connection to an address on the tailnet. 79 | // 80 | // It is a pipe(2) on which you can use read(2), write(2), and close(2). 81 | // For extra control over the connection, see the tailscale_conn_* functions. 82 | typedef int tailscale_conn; 83 | 84 | // Returns the IP addresses of the the Tailscale server as 85 | // a comma separated list. 86 | // 87 | // The provided buffer must be of sufficient size to hold the concatenated 88 | // IPs as strings. This is typically , but maybe empty, or 89 | // contain any number of ips. The caller is responsible for parsing 90 | // the output. You may assume the output is a list of well-formed IPs. 91 | // 92 | // Returns: 93 | // 0 - Success 94 | // EBADF - sd is not a valid tailscale, or l or conn are not valid listeneras or connections 95 | // ERANGE - insufficient storage for buf 96 | extern int tailscale_getips(tailscale sd, char* buf, size_t buflen); 97 | 98 | // tailscale_dial connects to the address on the tailnet. 99 | // 100 | // The newly allocated connection is written to conn_out. 101 | // 102 | // network is a NUL-terminated string of the form "tcp", "udp", etc. 103 | // addr is a NUL-terminated string of an IP address or domain name. 104 | // 105 | // It will start the server if it has not been started yet. 106 | // 107 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 108 | extern int tailscale_dial(tailscale sd, const char* network, const char* addr, tailscale_conn* conn_out); 109 | 110 | // A tailscale_listener is a socket on the tailnet listening for connections. 111 | // 112 | // It is much like allocating a system socket(2) and calling listen(2). 113 | // Accept connections with tailscale_accept and close the listener with close. 114 | // 115 | // Under the hood, a tailscale_listener is one half of a socketpair itself, 116 | // used to move the connection fd from Go to C. This means you can use epoll 117 | // or its equivalent on a tailscale_listener to know if there is a connection 118 | // read to accept. 119 | typedef int tailscale_listener; 120 | 121 | // tailscale_listen listens for a connection on the tailnet. 122 | // 123 | // It is the spiritual equivalent to listen(2). 124 | // The newly allocated listener is written to listener_out. 125 | // 126 | // network is a NUL-terminated string of the form "tcp", "udp", etc. 127 | // addr is a NUL-terminated string of an IP address or domain name. 128 | // 129 | // It will start the server if it has not been started yet. 130 | // 131 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 132 | extern int tailscale_listen(tailscale sd, const char* network, const char* addr, tailscale_listener* listener_out); 133 | 134 | // Returns the remote address for an incoming connection for a particular listener. The address (eitehr ip4 or ip6) 135 | // will ge written to buf on on success. 136 | // Returns: 137 | // 0 - Success 138 | // EBADF - sd is not a valid tailscale, or l or conn are not valid listeneras or connections 139 | // ERANGE - insufficient storage for buf 140 | extern int tailscale_getremoteaddr(tailscale_listener l, tailscale_conn conn, char* buf, size_t buflen); 141 | 142 | 143 | // tailscale_accept accepts a connection on a tailscale_listener. 144 | // 145 | // It is the spiritual equivalent to accept(2). 146 | // 147 | // The newly allocated connection is written to conn_out. 148 | // 149 | // Returns: 150 | // 0 - success 151 | // EBADF - listener is not a valid tailscale 152 | // -1 - call tailscale_errmsg for details 153 | extern int tailscale_accept(tailscale_listener listener, tailscale_conn* conn_out); 154 | 155 | // tailscale_loopback starts a loopback address server. 156 | // 157 | // The server has multiple functions. 158 | // 159 | // It can be used as a SOCKS5 proxy onto the tailnet. 160 | // Authentication is required with the username "tsnet" and 161 | // the value of proxy_cred used as the password. 162 | // 163 | // The HTTP server also serves out the "LocalAPI" on /localapi. 164 | // As the LocalAPI is powerful, access to endpoints requires BOTH passing a 165 | // "Sec-Tailscale: localapi" HTTP header and passing local_api_cred as 166 | // the basic auth password. 167 | // 168 | // The pointers proxy_cred_out and local_api_cred_out must be non-NIL 169 | // and point to arrays that can hold 33 bytes. The first 32 bytes are 170 | // the credential and the final byte is a NUL terminator. 171 | // 172 | // If tailscale_loopback returns, then addr_our, proxy_cred_out, 173 | // and local_api_cred_out are all NUL-terminated. 174 | // 175 | // Returns zero on success or -1 on error, call tailscale_errmsg for details. 176 | extern int tailscale_loopback(tailscale sd, char* addr_out, size_t addrlen, char* proxy_cred_out, char* local_api_cred_out); 177 | 178 | // tailscale_enable_funnel_to_localhost_plaintext_http1 configures sd to have 179 | // Tailscale Funnel enabled, routing requests from the public web 180 | // (without any authentication) down to this Tailscale node, requesting new 181 | // LetsEncrypt TLS certs as needed, terminating TLS, and proxying all incoming 182 | // HTTPS requests to http://127.0.0.1:localhostPort without TLS. 183 | // 184 | // There should be a plaintext HTTP/1 server listening on 127.0.0.1:localhostPort 185 | // or tsnet will serve HTTP 502 errors. 186 | // 187 | // Expect junk traffic from the internet from bots watching the public CT logs. 188 | // 189 | // Returns: 190 | // 0 - success 191 | // -1 - other error, details printed to the tsnet logger 192 | extern int tailscale_enable_funnel_to_localhost_plaintext_http1(tailscale sd, int localhostPort); 193 | 194 | // tailscale_errmsg writes the details of the last error to buf. 195 | // 196 | // After returning, buf is always NUL-terminated. 197 | // 198 | // Returns: 199 | // 0 - success 200 | // EBADF - sd is not a valid tailscale 201 | // ERANGE - insufficient storage for buf 202 | extern int tailscale_errmsg(tailscale sd, char* buf, size_t buflen); 203 | 204 | 205 | #ifdef __cplusplus 206 | } 207 | #endif 208 | 209 | #endif 210 | -------------------------------------------------------------------------------- /tailscale_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/tailscale/libtailscale/tsnetctest" 8 | ) 9 | 10 | func TestConn(t *testing.T) { 11 | tsnetctest.RunTestConn(t) 12 | 13 | // RunTestConn cleans up after itself, so there shouldn't be 14 | // anything left in the global maps. 15 | 16 | servers.mu.Lock() 17 | rem := len(servers.m) 18 | servers.mu.Unlock() 19 | 20 | if rem > 0 { 21 | t.Fatalf("want no remaining tsnet objects, got %d", rem) 22 | } 23 | 24 | var remConns, remLns int 25 | 26 | for i := 0; i < 50; i++ { 27 | conns.mu.Lock() 28 | remConns = len(conns.m) 29 | conns.mu.Unlock() 30 | 31 | listeners.mu.Lock() 32 | remLns = len(listeners.m) 33 | listeners.mu.Unlock() 34 | 35 | if remConns == 0 && remLns == 0 { 36 | break 37 | } 38 | 39 | // We are waiting for cleanup goroutines to finish. 40 | // 41 | // libtailscale closes one side of a socketpair and 42 | // then Go responds to the other side being unreadable 43 | // by closing the connections and listeners. 44 | // 45 | // This is inherently asynchronous. 46 | // Without ditching the standard close(2) and having our 47 | // own close functions. 48 | // 49 | // So we spin for a while 50 | time.Sleep(100 * time.Millisecond) 51 | } 52 | 53 | if remConns > 0 { 54 | t.Errorf("want no remaining tsnet_conn objects, got %d", remConns) 55 | } 56 | 57 | if remLns > 0 { 58 | t.Errorf("want no remaining tsnet_listener objects, got %d", remLns) 59 | } 60 | } 61 | 62 | func TestExtractIP(t *testing.T) { 63 | ipv4 := "1.23.33.4:12343" 64 | ipv6 := "[1::2234::34fc::44]:56576" 65 | 66 | got4 := extractIP(ipv4) 67 | got6 := extractIP(ipv6) 68 | 69 | want4 := "1.23.33.4" 70 | want6 := "[1::2234::34fc::44]" 71 | 72 | if got4 != want4 { 73 | t.Errorf("ipv4 port stripping failed") 74 | } 75 | 76 | if got6 != want6 { 77 | t.Errorf("ipv6 port stripping failed %s != %s", got6, want6) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tsnetctest/tsnetctest.go: -------------------------------------------------------------------------------- 1 | // Package tsnetctest tests the libtailscale C bindings. 2 | // 3 | // It is used by tailscale_test.go, because you are not allowed to 4 | // use the 'import "C"' directive in tests. 5 | package tsnetctest 6 | 7 | /* 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include "../tailscale.h" 14 | 15 | char* tmps1; 16 | char* tmps2; 17 | 18 | char* control_url = 0; 19 | 20 | int addrlen = 128; 21 | char* addr = NULL; 22 | char* proxy_cred = NULL; 23 | char* local_api_cred = NULL; 24 | 25 | int errlen = 512; 26 | char* err = NULL; 27 | 28 | tailscale s1, s2; 29 | 30 | int set_err(tailscale sd, char tag) { 31 | err[0] = tag; 32 | err[1] = ':'; 33 | err[2] = ' '; 34 | tailscale_errmsg(sd, &err[3], errlen-3); 35 | return 1; 36 | } 37 | 38 | int test_conn() { 39 | err = calloc(errlen, 1); 40 | addr = calloc(addrlen, 1); 41 | proxy_cred = calloc(33, 1); 42 | local_api_cred = calloc(33, 1); 43 | int ret; 44 | 45 | s1 = tailscale_new(); 46 | if ((ret = tailscale_set_control_url(s1, control_url)) != 0) { 47 | return set_err(s1, '0'); 48 | } 49 | if ((ret = tailscale_set_dir(s1, tmps1)) != 0) { 50 | return set_err(s1, '1'); 51 | } 52 | if ((ret = tailscale_set_logfd(s1, -1)) != 0) { 53 | return set_err(s1, '2'); 54 | } 55 | if ((ret = tailscale_up(s1)) != 0) { 56 | return set_err(s1, '3'); 57 | } 58 | 59 | s2 = tailscale_new(); 60 | if ((ret = tailscale_set_control_url(s2, control_url)) != 0) { 61 | return set_err(s2, '4'); 62 | } 63 | if ((ret = tailscale_set_dir(s2, tmps2)) != 0) { 64 | return set_err(s2, '5'); 65 | } 66 | if ((ret = tailscale_set_logfd(s2, -1)) != 0) { 67 | return set_err(s1, '6'); 68 | } 69 | if ((ret = tailscale_up(s2)) != 0) { 70 | return set_err(s2, '7'); 71 | } 72 | 73 | tailscale_listener ln; 74 | if ((ret = tailscale_listen(s1, "tcp", ":8081", &ln)) != 0) { 75 | return set_err(s1, '8'); 76 | } 77 | 78 | tailscale_conn w; 79 | if ((ret = tailscale_dial(s2, "tcp", "100.64.0.1:8081", &w)) != 0) { 80 | return set_err(s2, '9'); 81 | } 82 | 83 | tailscale_conn r; 84 | if ((ret = tailscale_accept(ln, &r)) != 0) { 85 | return set_err(s2, 'a'); 86 | } 87 | 88 | const char want[] = "hello"; 89 | ssize_t wret; 90 | if ((wret = write(w, want, sizeof(want))) != sizeof(want)) { 91 | snprintf(err, errlen, "short write: %zd, errno: %d (%s)", wret, errno, strerror(errno)); 92 | return 1; 93 | } 94 | char* got = malloc(sizeof(want)); 95 | if ((wret = read(r, got, sizeof(want))) != sizeof("hello")) { 96 | snprintf(err, errlen, "short read: %zd on fd %d, errno: %d (%s)", wret, r, errno, strerror(errno)); 97 | return 1; 98 | } 99 | if (strncmp(got, want, sizeof(want)) != 0) { 100 | snprintf(err, errlen, "got '%s' want '%s'", got, want); 101 | return 1; 102 | } 103 | 104 | if ((ret = close(w)) != 0) { 105 | snprintf(err, errlen, "failed to close w: %d (%s)", errno, strerror(errno)); 106 | return 1; 107 | } 108 | if ((ret = close(r)) != 0) { 109 | snprintf(err, errlen, "failed to close r: %d (%s)", errno, strerror(errno)); 110 | return 1; 111 | } 112 | if ((ret = close(ln)) != 0) { 113 | return set_err(s1, 'a'); 114 | } 115 | if ((ret = close(ln)) == 0 || errno != EBADF) { 116 | snprintf(err, errlen, "double tailscale_listener close = %d (errno %d: %s), want EBADF", ret, errno, strerror(errno)); 117 | return 1; 118 | } 119 | 120 | if ((ret = tailscale_loopback(s1, addr, addrlen, proxy_cred, local_api_cred)) != 0) { 121 | return set_err(s1, 'b'); 122 | } 123 | 124 | return 0; 125 | } 126 | 127 | int close_conn() { 128 | if (tailscale_close(s1) != 0) { 129 | return set_err(s1, 'd'); 130 | } 131 | if (tailscale_close(s2) != 0) { 132 | return set_err(s2, 'e'); 133 | } 134 | return 0; 135 | } 136 | */ 137 | import "C" 138 | import ( 139 | "context" 140 | "flag" 141 | "io" 142 | "net/http" 143 | "net/http/httptest" 144 | "os" 145 | "path/filepath" 146 | "testing" 147 | "time" 148 | 149 | "tailscale.com/net/netns" 150 | "tailscale.com/tstest/integration" 151 | "tailscale.com/tstest/integration/testcontrol" 152 | "tailscale.com/types/logger" 153 | ) 154 | 155 | var verboseDERP = flag.Bool("verbose-derp", false, "if set, print DERP and STUN logs") 156 | 157 | func RunTestConn(t *testing.T) { 158 | // Corp#4520: don't use netns for tests. 159 | netns.SetEnabled(false) 160 | t.Cleanup(func() { 161 | netns.SetEnabled(true) 162 | }) 163 | 164 | derpLogf := logger.Discard 165 | if *verboseDERP { 166 | derpLogf = t.Logf 167 | } 168 | derpMap := integration.RunDERPAndSTUN(t, derpLogf, "127.0.0.1") 169 | control := &testcontrol.Server{ 170 | DERPMap: derpMap, 171 | } 172 | control.HTTPTestServer = httptest.NewUnstartedServer(control) 173 | control.HTTPTestServer.Start() 174 | t.Cleanup(control.HTTPTestServer.Close) 175 | controlURL := control.HTTPTestServer.URL 176 | t.Logf("testcontrol listening on %s", controlURL) 177 | 178 | C.control_url = C.CString(controlURL) 179 | 180 | tmp := t.TempDir() 181 | tmps1 := filepath.Join(tmp, "s1") 182 | os.MkdirAll(tmps1, 0755) 183 | C.tmps1 = C.CString(tmps1) 184 | tmps2 := filepath.Join(tmp, "s2") 185 | os.MkdirAll(tmps2, 0755) 186 | C.tmps2 = C.CString(tmps2) 187 | 188 | if C.test_conn() != 0 { 189 | t.Fatal(C.GoString(C.err)) 190 | } 191 | 192 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 193 | defer cancel() 194 | 195 | localAPIStatus := "http://" + C.GoString(C.addr) + "/localapi/v0/status" 196 | t.Logf("fetching local API status from %q", localAPIStatus) 197 | req, err := http.NewRequestWithContext(ctx, "GET", localAPIStatus, nil) 198 | if err != nil { 199 | t.Fatal(err) 200 | } 201 | req.Header.Set("Sec-Tailscale", "localapi") 202 | req.SetBasicAuth("", C.GoString(C.local_api_cred)) 203 | res, err := http.DefaultClient.Do(req) 204 | if err != nil { 205 | t.Fatal(err) 206 | } 207 | b, err := io.ReadAll(res.Body) 208 | res.Body.Close() 209 | if err != nil { 210 | t.Fatal(err) 211 | } 212 | if res.StatusCode != 200 { 213 | t.Errorf("/status: %d: %s", res.StatusCode, b) 214 | } 215 | 216 | if C.close_conn() != 0 { 217 | t.Fatal(C.GoString(C.err)) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /tstestcontrol/Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | all: 5 | go build -buildmode=c-archive -o libtstestcontrol.a 6 | 7 | clean: 8 | rm libtstestcontrol.a 9 | rm libtstestcontrol.h 10 | 11 | -------------------------------------------------------------------------------- /tstestcontrol/tstestcontrol.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | #include "tstestcontrol.h" 5 | #include 6 | 7 | // Functions exported by go 8 | extern long long RunControl(char* buf, size_t buflen); 9 | extern void StopControl(); 10 | 11 | // run_control starts an ephemeral control test server on localhost. 12 | // buf must be a char* of sufficient size to hold the resulting URL 13 | // stop_control must be called when you are finished with the instance 14 | // 15 | // returns -1 on failure, 0 on success 16 | int run_control(char* buf, size_t buflen) { 17 | return RunControl(buf, buflen); 18 | } 19 | 20 | // stop_control() stops the e 21 | void stop_control() { 22 | StopControl(); 23 | } -------------------------------------------------------------------------------- /tstestcontrol/tstestcontrol.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // A Go c-archive of tsnet integration/control test utilities 5 | // This mirrors athe functionality in tstest without the depenency 6 | // on go tests so it can be bundled as a static library and used to drive 7 | // integration tests on other platforms 8 | package main 9 | 10 | import "C" 11 | 12 | //#include "errno.h" 13 | import ( 14 | "context" 15 | "crypto/tls" 16 | "errors" 17 | "net" 18 | "net/http" 19 | "net/http/httptest" 20 | "sync" 21 | "unsafe" 22 | 23 | "tailscale.com/net/netaddr" 24 | "tailscale.com/net/netns" 25 | "tailscale.com/net/stun" 26 | "tailscale.com/tstest/integration/testcontrol" 27 | "tailscale.com/types/logger" 28 | 29 | "tailscale.com/derp" 30 | "tailscale.com/derp/derphttp" 31 | "tailscale.com/tailcfg" 32 | "tailscale.com/types/key" 33 | "tailscale.com/types/nettype" 34 | ) 35 | 36 | func main() {} 37 | 38 | //export StopControl 39 | func StopControl() { 40 | netns.SetEnabled(true) 41 | if control != nil { 42 | control.HTTPTestServer.Close() 43 | } 44 | cleanup() 45 | control = nil 46 | } 47 | 48 | var control *testcontrol.Server 49 | var cleanup func() 50 | 51 | // TODO(barnstar): by purging this of the go testing dependency, we lost some logging and things fail silently. 52 | // that needs to be plumbed back in. 53 | 54 | //export RunControl 55 | func RunControl(buf *C.char, buflen C.size_t) C.int { 56 | if control != nil { 57 | return -1 58 | } 59 | 60 | if buf == nil { 61 | return -1 62 | } else if buflen == 0 { 63 | return -1 64 | } 65 | out := unsafe.Slice((*byte)(unsafe.Pointer(buf)), buflen) 66 | 67 | // Corp#4520: don't use netns for tests. 68 | netns.SetEnabled(false) 69 | 70 | derpLogf := logger.Discard 71 | derpMap, c, err := runDERPAndSTUN(derpLogf, "127.0.0.1") 72 | if err != nil { 73 | out[0] = '\x00' 74 | return -1 75 | } 76 | 77 | control := &testcontrol.Server{ 78 | DERPMap: derpMap, 79 | } 80 | control.HTTPTestServer = httptest.NewUnstartedServer(control) 81 | control.HTTPTestServer.Start() 82 | controlURL := control.HTTPTestServer.URL 83 | cleanup = c 84 | 85 | n := copy(out, controlURL) 86 | out[n] = '\x00' 87 | 88 | return 0 89 | } 90 | 91 | // RunDERPAndSTUN runs a local DERP and STUN server for tests, returning the derpMap 92 | // that clients should use. This creates resources that must be cleaned up with the 93 | // returned cleanup function. 94 | func runDERPAndSTUN(logf logger.Logf, ipAddress string) (derpMap *tailcfg.DERPMap, cleanup func(), err error) { 95 | d := derp.NewServer(key.NewNode(), logf) 96 | 97 | ln, err := net.Listen("tcp", net.JoinHostPort(ipAddress, "0")) 98 | if err != nil { 99 | return nil, nil, err 100 | } 101 | 102 | httpsrv := httptest.NewUnstartedServer(derphttp.Handler(d)) 103 | httpsrv.Listener.Close() 104 | httpsrv.Listener = ln 105 | httpsrv.Config.ErrorLog = logger.StdLogger(logf) 106 | httpsrv.Config.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) 107 | httpsrv.StartTLS() 108 | 109 | stunAddr, stunCleanup, err := serveWithPacketListener(nettype.Std{}) 110 | if err != nil { 111 | return nil, nil, err 112 | } 113 | 114 | m := &tailcfg.DERPMap{ 115 | Regions: map[int]*tailcfg.DERPRegion{ 116 | 1: { 117 | RegionID: 1, 118 | RegionCode: "test", 119 | Nodes: []*tailcfg.DERPNode{ 120 | { 121 | Name: "t1", 122 | RegionID: 1, 123 | HostName: ipAddress, 124 | IPv4: ipAddress, 125 | IPv6: "none", 126 | STUNPort: stunAddr.Port, 127 | DERPPort: httpsrv.Listener.Addr().(*net.TCPAddr).Port, 128 | InsecureForTests: true, 129 | STUNTestIP: ipAddress, 130 | }, 131 | }, 132 | }, 133 | }, 134 | } 135 | 136 | logf("DERP httpsrv listener: %v", httpsrv.Listener.Addr()) 137 | 138 | cleanupfn := func() { 139 | httpsrv.CloseClientConnections() 140 | httpsrv.Close() 141 | d.Close() 142 | stunCleanup() 143 | ln.Close() 144 | } 145 | 146 | return m, cleanupfn, nil 147 | } 148 | 149 | type stunStats struct { 150 | mu sync.Mutex 151 | // +checklocks:mu 152 | readIPv4 int 153 | // +checklocks:mu 154 | readIPv6 int 155 | } 156 | 157 | func serveWithPacketListener(ln nettype.PacketListener) (addr *net.UDPAddr, cleanupFn func(), err error) { 158 | // TODO(crawshaw): use stats to test re-STUN logic 159 | var stats stunStats 160 | 161 | pc, err := ln.ListenPacket(context.Background(), "udp4", ":0") 162 | if err != nil { 163 | return nil, nil, err 164 | } 165 | addr = pc.LocalAddr().(*net.UDPAddr) 166 | if len(addr.IP) == 0 || addr.IP.IsUnspecified() { 167 | addr.IP = net.ParseIP("127.0.0.1") 168 | } 169 | doneCh := make(chan struct{}) 170 | go runSTUN(pc.(nettype.PacketConn), &stats, doneCh) 171 | return addr, func() { 172 | pc.Close() 173 | <-doneCh 174 | }, nil 175 | } 176 | 177 | func runSTUN(pc nettype.PacketConn, stats *stunStats, done chan<- struct{}) { 178 | defer close(done) 179 | 180 | var buf [64 << 10]byte 181 | for { 182 | n, src, err := pc.ReadFromUDPAddrPort(buf[:]) 183 | if err != nil { 184 | if errors.Is(err, net.ErrClosed) { 185 | return 186 | } 187 | continue 188 | } 189 | src = netaddr.Unmap(src) 190 | pkt := buf[:n] 191 | if !stun.Is(pkt) { 192 | continue 193 | } 194 | txid, err := stun.ParseBindingRequest(pkt) 195 | if err != nil { 196 | continue 197 | } 198 | 199 | stats.mu.Lock() 200 | if src.Addr().Is4() { 201 | stats.readIPv4++ 202 | } else { 203 | stats.readIPv6++ 204 | } 205 | stats.mu.Unlock() 206 | 207 | res := stun.Response(txid, src) 208 | if _, err := pc.WriteToUDPAddrPort(res, src); err != nil { 209 | // TODO(barnstar): inject logging from C 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /tstestcontrol/tstestcontrol.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | #include 5 | 6 | #ifndef TAILSCALE_H 7 | #define TAILSCALE_H 8 | 9 | #ifdef __cplusplus 10 | extern "C" { 11 | #endif 12 | 13 | // External definitions for libtstestcontrol.h 14 | 15 | // Runs a new control. Returns the URL in the buffer 16 | // returns 0 on success, an error code on failure 17 | extern int run_control(char* buf, size_t buflen); 18 | 19 | // Stops the running control 20 | extern void stop_control(); 21 | 22 | #ifdef __cplusplus 23 | } 24 | #endif 25 | 26 | #endif --------------------------------------------------------------------------------