Tailscale for Perl

Perl bindings for tailscale-rs, a Rust implementation of Tailscale similar to Go's tsnet. This lets you join a Tailscale network directly from a Perl program with no tailscaled daemon required.

You can dial outbound TCP connections to machines on your tailnet and listen for inbound connections, making it straightforward to write both clients and servers that communicate exclusively over your private Tailscale network.

Note: tailscale-rs is experimental, pre-1.0 software. The Rust library sets TS_RS_EXPERIMENT=this_is_unstable_software internally. APIs may change.

Prerequisites

Building the shared library

Clone tailscale-rs and build the C FFI shared library:

git clone https://github.com/tailscale/tailscale-rs.git
cd tailscale-rs
cargo build --release -p ts_ffi

This produces target/release/libtailscalers.so. Tell the Perl module where to find it by setting TS_LIB_PATH:

export TS_LIB_PATH=/path/to/tailscale-rs/target/release

Installing Perl dependencies

cpanm FFI::Platypus FFI::CheckLib HTTP::Request HTTP::Response

Or let Makefile.PL pull them in:

perl Makefile.PL && make

Quick start

Joining your tailnet

Every program needs a config path (a JSON file that stores cryptographic keys -- created automatically on first run) and an auth key (a Tailscale auth key to authorize the node).

use Tailscale;

my $ts = Tailscale->new(
    config_path => "/tmp/my-app-state.json",
    auth_key    => "tskey-auth-...",
);

my $ip = $ts->ipv4_addr();   # e.g. "100.64.0.5"
print "I'm on the tailnet at $ip\n";

HTTP client -- fetching a page from a tailnet peer

use Tailscale;

my $ts = Tailscale->new(
    config_path => "state.json",
    auth_key    => "tskey-auth-...",
);

my $stream = $ts->tcp_connect("100.100.100.100:80");
$stream->send_all("GET / HTTP/1.0\r\nHost: my-server\r\nConnection: close\r\n\r\n");

my $response = "";
while (defined(my $chunk = $stream->recv(4096))) {
    $response .= $chunk;
}
print $response;

HTTP server -- serving requests on your tailnet

use Tailscale;
use Tailscale::HttpServer;
use HTTP::Response;

my $ts = Tailscale->new(
    config_path => "state.json",
    auth_key    => "tskey-auth-...",
);

print "Listening on " . $ts->ipv4_addr() . ":8080\n";

my $httpd = Tailscale::HttpServer->new(tailscale => $ts, port => 8080);
$httpd->run(sub {
    my ($req) = @_;    # HTTP::Request object

    my $res = HTTP::Response->new(200);
    $res->header('Content-Type' => 'text/plain');
    $res->content("Hello from Perl on Tailscale!\n");
    return $res;
});

Then from any other machine on your tailnet:

curl http://<tailscale-ip>:8080/

API reference

Tailscale

my $ts = Tailscale->new(
    config_path => "state.json",    # required -- key state file (created if missing)
    auth_key    => "tskey-auth-...",  # optional -- omit if already authorized
    hostname    => "my-app",        # optional -- requested tailnet hostname
    control_url => "https://...",     # optional -- custom control server (for testing)
);

$ts->ipv4_addr()                # returns e.g. "100.64.0.1"
$ts->tcp_connect("ip:port")     # returns Tailscale::TcpStream
$ts->tcp_listen($port)          # returns Tailscale::TcpListener
$ts->close()                    # shuts down the node

Tailscale::TcpListener

my $listener = $ts->tcp_listen(8080);
my $stream   = $listener->accept();    # blocks until a connection arrives
$listener->close();

Tailscale::TcpStream

$stream->send($data)        # send bytes, returns number sent
$stream->send_all($data)    # send all bytes (loops internally)
$stream->recv($maxlen)      # receive up to $maxlen bytes; returns undef on EOF
$stream->close()

Tailscale::HttpServer

A minimal HTTP/1.0 server that uses HTTP::Request for parsing and HTTP::Response for formatting. Runs on top of the Tailscale TCP primitives.

my $httpd = Tailscale::HttpServer->new(tailscale => $ts, port => 8080);

# Serve forever:
$httpd->run(sub {
    my ($req) = @_;          # HTTP::Request
    return HTTP::Response->new(200, "OK", ['Content-Type' => 'text/plain'], "hi\n");
});

Running the examples

# Terminal 1: start an HTTP server on your tailnet
TS_LIB_PATH=/path/to/tailscale-rs/target/release \
  perl -Ilib examples/http-server.pl state-server.json tskey-auth-...

# Terminal 2: fetch from it
TS_LIB_PATH=/path/to/tailscale-rs/target/release \
  perl -Ilib examples/http-client.pl state-client.json tskey-auth-... 100.x.y.z:8080

Running the tests

The integration tests require Go (to build a test control server with DERP relay) and the compiled libtailscalers.so.

# Build everything
make -f Makefile.dev all

# Run tests
make -f Makefile.dev test

Or manually:

cd testenv && go build -o testenv . && cd ..
TS_LIB_PATH=/path/to/tailscale-rs/target/release prove -Ilib t/

The tests spin up a local Tailscale control plane (using Go's testcontrol package), a DERP relay server, and a STUN server. Two Perl nodes join this private test network and exchange an HTTP request/response over it.

Architecture

┌──────────────┐     FFI      ┌────────────────────┐
│  Perl code   │─────────────▶│  libtailscalers    │
│  (Tailscale  │FFI::Platypus │  (Rust cdylib)     │
│   module)    │              │  from tailscale-rs │
└──────────────┘              └────────────────────┘
                                       │
                              WireGuard + DERP
                                       │
                              ┌──────────────────┐
                              │   Your tailnet   │
                              └──────────────────┘

The Perl module uses FFI::Platypus to call the C FFI functions exported by tailscale-rs (ts_init, ts_tcp_connect, ts_tcp_send, etc.). No XS or C compiler is needed at Perl build time -- only the pre-built libtailscalers.so.

License

BSD-3-Clause, matching tailscale-rs.