https://github.com/rajsinghtech/tailbridge
Cross-tailnet TCP bridge using Tailscale VIP services
https://github.com/rajsinghtech/tailbridge
Last synced: 3 months ago
JSON representation
Cross-tailnet TCP bridge using Tailscale VIP services
- Host: GitHub
- URL: https://github.com/rajsinghtech/tailbridge
- Owner: rajsinghtech
- License: mit
- Created: 2026-03-21T07:28:32.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-03-21T13:23:02.000Z (3 months ago)
- Last Synced: 2026-03-22T00:06:35.765Z (3 months ago)
- Language: Go
- Size: 58.6 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# tailbridge
Cross-tailnet TCP bridge. Makes machines on one Tailscale tailnet reachable from another using [VIP services](https://tailscale.com/kb/1552/tailscale-services).
## Why tailbridge?
Separate tailnets can't route to each other — each is its own trust boundary with its own ACLs, keys, and DNS. tailbridge proxies TCP traffic across the boundary with full name resolution. No firewall rules, no VPN peering, no IP conflicts.
## Who uses this
- Platform/SRE teams bridging prod vs. dev/staging or multi-account org tailnets
- MSPs exposing customer services into their ops tailnet without full network membership
- Compliance environments (OT/IoT, HIPAA) sharing specific ports across isolated tailnets
- Cross-org projects scoping temporary, port-limited access into a partner's tailnet
```mermaid
flowchart TD
subgraph src["Tailnet 1"]
machines["web-1\ndb-1\n(your servers)"]
end
subgraph tb["tailbridge"]
direction LR
discover["discovers\nnew machines"]
proxy["forwards\ntraffic"]
end
subgraph dst["Tailnet 2"]
access["web-1.tailnet1.ts.net\ndb-1.tailnet1.ts.net"]
client["client"]
end
machines --> discover
discover -->|"creates access points"| access
client -->|"connects by name"| access
access --> proxy
proxy --> machines
```
tailbridge runs two [tsnet](https://pkg.go.dev/tailscale.com/tsnet) servers in one process, each joined to a different tailnet. It discovers machines by tag on the source tailnet, creates a VIP service for each on the destination tailnet, and forwards TCP connections through the source tailnet's WireGuard tunnel. An integrated DNS server resolves original FQDNs to VIP addresses, with automatic split-DNS configuration so clients use original hostnames transparently.
## Install
```sh
brew install rajsinghtech/tap/tailbridge
```
Or grab a binary from [releases](https://github.com/rajsinghtech/tailbridge/releases), or use Docker:
```sh
docker pull ghcr.io/rajsinghtech/tailbridge:latest
```
## Quickstart
Create OAuth clients on both tailnets with `devices:read`, `dns:write` scopes.
`config.yaml`:
```yaml
bridge:
tailnets:
tailnet1:
clientId: ${TAILNET1_CLIENT_ID}
clientSecret: ${TAILNET1_CLIENT_SECRET}
tags: ["tag:bridge"]
tailnet2:
clientId: ${TAILNET2_CLIENT_ID}
clientSecret: ${TAILNET2_CLIENT_SECRET}
tags: ["tag:bridge"]
directions:
"tailnet1>tailnet2":
serviceTags: ["tag:bridge-svc"]
rules:
- from: tailnet1
to: tailnet2
discover:
tags: ["tag:server"]
ports: [22, 80, 443]
pollInterval: 30s
dialTimeout: 10s
```
```sh
tailbridge -config config.yaml
```
Machines tagged `tag:server` on `tailnet1` are now reachable from `tailnet2` on ports 22, 80, 443. Each gets a VIP like `svc:web-1-tailnet1-ts-net`.
## ACL setup
Both tailnets need:
```jsonc
{
"tagOwners": {
"tag:bridge": ["autogroup:admin"],
"tag:bridge-svc": ["tag:bridge"]
},
"autoApprovers": {
"services": { "tag:bridge-svc": ["tag:bridge"] }
},
"grants": [
{
// Clients can reach bridged services
"src": ["autogroup:member", "autogroup:tagged"],
"dst": ["tag:bridge-svc", "tag:bridge"],
"ip": ["*"]
},
{
// Bridge can reach machines it discovers
"src": ["tag:bridge"],
"dst": ["tag:server"],
"ip": ["*"]
}
]
}
```
Adjust `dst` in the second grant to match whatever tags your discovered machines use.
## Config reference
| Field | Description |
|-------|-------------|
| `bridge.tailnets` | Map of name → OAuth credentials + node tags. Keys must be `[a-z0-9-]+`. |
| `bridge.directions` | Per-direction settings keyed by `"from>to"`. |
| `directions.*.serviceTags` | Required. ACL tags applied to created VIP services. |
| `directions.*.prefix` | Optional prefix on VIP service names. |
| `directions.*.dns.enabled` | Run a DNS server for this direction on TCP port 53. |
| `directions.*.dns.splitDns` | Auto-configure split-DNS on the destination tailnet. |
| `directions.*.dns.cleanupOnShutdown` | Remove split-DNS entries on shutdown (default: false). |
| `bridge.rules` | Discovery rules: `from`, `to`, `discover.tags`, `discover.ports`. |
| `bridge.pollInterval` | Device poll interval (min 5s, default 30s). |
| `bridge.dialTimeout` | TCP dial timeout to source machines (default 10s). |
## DNS
With `dns.enabled: true`, tailbridge runs an authoritative DNS server on the destination tailnet (TCP port 53, via a VIP service). It resolves source machine FQDNs to their bridged VIP IPs.
With `dns.splitDns: true`, it also configures split-DNS on the destination tailnet so queries for `*.source-tailnet.ts.net` route to the bridge DNS automatically. Clients can then use the original FQDN (e.g. `web-1.tailnet1.ts.net`) and it resolves to the VIP address.
## Bidirectional
Add directions and rules in both directions:
```yaml
directions:
"tailnet1>tailnet2":
serviceTags: ["tag:bridge-svc"]
"tailnet2>tailnet1":
serviceTags: ["tag:bridge-svc"]
rules:
- from: tailnet1
to: tailnet2
discover:
tags: ["tag:server"]
ports: [22, 80, 443]
- from: tailnet2
to: tailnet1
discover:
tags: ["tag:iot"]
ports: [80]
```
## Flags
| Flag | Default | Description |
|------|---------|-------------|
| `-config` | `config.yaml` | Config file path |
| `-log-level` | `info` | `debug` / `info` / `warn` / `error` |
## Docker
```sh
docker run \
-e TAILNET1_CLIENT_ID=... \
-e TAILNET1_CLIENT_SECRET=... \
-e TAILNET2_CLIENT_ID=... \
-e TAILNET2_CLIENT_SECRET=... \
-v $(pwd)/config.yaml:/config.yaml \
ghcr.io/rajsinghtech/tailbridge:latest \
-config /config.yaml
```
## How it works
Each poll cycle: Devices API → filter by tags → diff against previous → create/delete VIP services → start/stop TCP listeners → update DNS records → configure split-DNS. Connections are forwarded via `tsnet.Server.Dial()` through the source tailnet's WireGuard tunnel.
## License
[MIT](LICENSE)