tailscale / tailscale/tailscale-rs

Best way to proxy HTTP using the Python library?

Open
#243 4 comments 0 reactions 1 assignee View on GitHub

@dylan-tailscale is already working on this.

Since Jun 17, 2026.

bug enhancement integration support language support
Dominant language
Rust
Stars
1.2k
Forks
61
Avg merge
2d 13h
Merged PRs (30d)
22

Description

I got a prototype Datasette plugin working using tailscale-py:

https://github.com/datasette/datasette-tailscale

It's pretty neat, you can do this with it:

uv tool install datasette>=1.0a32
datasette install datasette-tailscale
datasette tailscale mydata.db --ts-authkey tskey-auth-xxxx --ts-hostname datasette-demo

And now http://datasette-demo/ on your Tailnet should serve that Datasette instance.

But... I mostly vibe-coded it and the solution for serving that localhost port over Tailscale is a little weird:

https://github.com/datasette/datasette-tailscale/blob/7f5ac3e3ebe56caf7f294c1c871a650619eaeb8e/datasette_tailscale/__init__.py#L214-L255

dev = await tailscale.connect(key_file, authkey, hostname=ts_hostname)
listener = await dev.tcp_listen((ipv4, ts_port))
try:
    while True:
        stream = await listener.accept()
        asyncio.create_task(_proxy(stream, free_port))
finally:
    server.should_exit = True
    await uvicorn_task

Here's that _proxy() helper (it took some iteration to get things to feel right, hence the Nagle bit):

async def _proxy(ts_stream, port):
    "Bridge one tailnet TCP stream to a fresh loopback connection to Datasette."
    try:
        reader, writer = await asyncio.open_connection("127.0.0.1", port)
    except OSError:
        return

    # Disable Nagle on the loopback socket. Without this, small HTTP writes
    # (headers, the tail of a response) can sit unflushed waiting for an ACK,
    # which shows up as responses dribbling through or appearing to hang.
    sock = writer.get_extra_info("socket")
    if sock is not None:
        try:
            sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        except OSError:
            pass

    async def tailnet_to_local():
        try:
            while True:
                data = await ts_stream.recv()
                if not data:
                    break
                writer.write(data)
                await writer.drain()
        except Exception:
            pass
        # Half-close: tell the backend we're done sending, but keep the socket
        # open so it can still write its response back to us. Fully closing here
        # would race the response and truncate it.
        try:
            if writer.can_write_eof():
                writer.write_eof()
        except Exception:
            pass

    async def local_to_tailnet():
        try:
            while True:
                data = await reader.read(65536)
                if not data:
                    break
                # send() may transmit fewer bytes than offered.
                while data:
                    sent = await ts_stream.send(data)
                    data = data[sent:]
        except Exception:
            pass

    try:
        await asyncio.gather(tailnet_to_local(), local_to_tailnet())
    finally:
        try:
            writer.close()
        except Exception:
            pass

Is this a good pattern? It feels to me like what I'm doing here - proxying a localhost port via Tailscale - should be a very common use-case, it would be great if the Python library had a more ergonomic solution (or maybe my solution is junk and there's a better way already.)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.