Skip to content
rajsinghtechPublic

About

Network flow visualizer for Tailscale - Requires Premium TS plans

Topics

Resources

Stars

201 stars

Watchers

1 watching

Forks

Repository files navigation

TSFlow - Tailscale Network Flow Visualizer

A real-time network traffic visualization dashboard for Tailscale networks. Monitor device connectivity, analyze bandwidth usage, and explore network flows with an interactive graph interface.

Traffic graph of a small homelab tailnet

Installation

Homebrew (macOS/Linux)

brew install rajsinghtech/tap/tsflow

Docker

docker pull ghcr.io/rajsinghtech/tsflow:latest

ghcr.io/rajsinghtech/tsflow:main tracks main and isn't a release.

Binary Download

Download from GitHub Releases.

Quick Start

Note: TSFlow requires Tailscale Network Flow Logs (Premium/Enterprise plans). Enable it in your Tailscale admin console.

Run with Homebrew

export TAILSCALE_OAUTH_CLIENT_ID=your-client-id
export TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret
tsflow

Open http://localhost:8080

Run with Docker

docker run -d \
  --name tsflow \
  -p 8080:8080 \
  -v tsflow_data:/app/data \
  -e TAILSCALE_OAUTH_CLIENT_ID=your-client-id \
  -e TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret \
  ghcr.io/rajsinghtech/tsflow:latest

Screenshots

All of these use made-up demo data.

Analytics: top talkers, ports, protocols and traffic types for the selected window.

Analytics page

Me: your own devices, top peers and new connections. Shows up when tsflow knows who you are (see Internal access control).

Me page

Time picker: presets, free text like last 3 hours or since 9am, local or UTC.

Time range picker

Configuration

Authentication

TSFlow supports OAuth (recommended) or API key authentication.

OAuth Setup:

  1. Go to OAuth clients in Tailscale Admin
  2. Create a new OAuth client with all:read scope
  3. Set TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET

API Key Setup:

  1. Go to API keys in Tailscale Admin
  2. Create a new API key
  3. Set TAILSCALE_API_KEY

Several tailnets

The single-tailnet environment variables configure one tailnet with id default. To watch more than one, leave TAILSCALE_TAILNET, TAILSCALE_API_KEY, and the OAuth client variables unset, and set TSFLOW_TAILNETS_FILE to a YAML or JSON file. Secrets are not written in the file. Each entry points at an environment variable or a file.

Data routes take an optional tailnet query parameter. With one configured tailnet the parameter can be omitted, and the JSON matches a single-tailnet install. With several tailnets, a missing parameter uses id default when that id is configured. If it is not, the response is 400 and lists the valid ids. An unknown id is 404. GET /api/tailnets returns each id, display name, and poller status, including the last error. The display name is the entry's display_name when one is set, and the tailnet name otherwise. With a display name, the entry also has tailnet, the tailnet name. It does not return credentials. The UI calls GET /api/tailnets once on load. With one tailnet it shows no switcher and leaves data requests unchanged. With several, a header switcher keeps the choice in the tailnet query parameter and sends that id on data requests.

Tailnet switcher: pick a tailnet from the header. Each entry shows its display_name, with the tailnet name and id underneath. Without a display_name, the tailnet name is the label.

Header tailnet switcher open, listing three tailnets named Home, Lab and Cloud
tailnets:
  - id: default
    tailnet: example.com
    api_key_env: DEFAULT_TAILSCALE_API_KEY
    s3_prefix: network/
  - id: lab
    tailnet: lab.example.com
    display_name: Lab
    oauth_client_id_file: /secrets/lab/client-id
    oauth_client_secret_file: /secrets/lab/client-secret
    s3_prefix: lab/network/

s3_prefix is optional. When it is omitted, the tailnet uses TSFLOW_S3_PREFIX. prefix is the same field. api_url and oauth_scopes are optional too. display_name is an optional label for the header switcher, such as Lab. The switcher shows it with the tailnet name and id underneath. displayName is the same field. Without it, the switcher shows the tailnet name with the id underneath. oauth_scopes is a comma-separated string or a list. An entry needs either api_key_env or api_key_file, or both OAuth client id and secret. Do not set both an environment variable and a file for the same secret. An auth block (type: oauth, api_key, or wif) is the other way to set credentials. Do not combine it with the flat credential fields. See docs/workload-identity.md.

An entry can also say where its flow logs live. flow_backend is api, s3, or gcs. bucket, region, endpoint, and s3_auth (static, aws_default, or gcs_adc) override the matching process settings. A field that is left out inherits the process value, so existing files that only set s3_prefix stay valid. role_arn with web_identity_token_file assumes that AWS role from the OIDC token in the file (AssumeRoleWithWebIdentity). That client does not read AWS_ROLE_ARN or AWS_WEB_IDENTITY_TOKEN_FILE, so two tailnets can use two token files. Static object-store keys stay on the process.

tailnets:
  - id: prod
    tailnet: prod.example.com
    api_key_env: PROD_TAILSCALE_API_KEY
    flow_backend: gcs
    bucket: example-prod-flow-logs
    prefix: network/
  - id: staging
    tailnet: staging.example.com
    api_key_env: STAGING_TAILSCALE_API_KEY
    flow_backend: s3
    s3_auth: aws_default
    bucket: example-staging-flow-logs
    region: us-east-1
    role_arn: arn:aws:iam::123456789012:role/tsflow-reader
    web_identity_token_file: /var/run/tsflow/staging/gcp-token
    prefix: staging/network/
  - id: lab
    tailnet: lab.example.com
    api_key_env: LAB_TAILSCALE_API_KEY
    flow_backend: api

prod reads a GCS bucket with Application Default Credentials. staging assumes a role in another account with a Google-issued ID token. lab has no streaming bucket and uses the Tailscale API logs endpoint.

JSON uses the same fields:

{"tailnets":[{"id":"default","tailnet":"example.com","api_key_env":"DEFAULT_TAILSCALE_API_KEY"}]}

Environment Variables

docs/environment.md lists every variable tsflow reads, including the older names that still work. A test keeps that list in sync with the code. The tables below cover the common ones.

Tailscale Authentication

Variable Description Default
TAILSCALE_OAUTH_CLIENT_ID OAuth client ID -
TAILSCALE_OAUTH_CLIENT_SECRET OAuth client secret -
TAILSCALE_OAUTH_SCOPES OAuth scopes (comma-separated) all:read
TAILSCALE_API_KEY API key (alternative to OAuth) -
TAILSCALE_AUTH oauth, api_key, or wif. Unset keeps the historical choice. -
TAILSCALE_WIF_CLIENT_ID Federated client id for API calls. Requires TAILSCALE_AUTH=wif. -
TAILSCALE_WIF_ID_TOKEN OIDC token for API workload identity. One token source only. -
TAILSCALE_WIF_ID_TOKEN_FILE File containing that OIDC token. -
TAILSCALE_WIF_AUDIENCE Audience used to request a platform token when no token is supplied. -
TAILSCALE_TAILNET Tailnet name (- for auto-detect) -
TAILSCALE_API_URL API endpoint https://api.tailscale.com
TSFLOW_TAILNETS_FILE YAML or JSON list of tailnets. Do not combine with the single-tailnet variables above. -

Server Settings

Variable Description Default
PORT Server port 8080
ENVIRONMENT development or production development
ALLOWED_CORS_ORIGINS Comma-separated origins allowed to call the API cross-origin. Unset, production allows none and development allows only loopback origins such as http://localhost:3000 unset
TSFLOW_TRUSTED_PROXIES Comma-separated proxy IPs or CIDRs allowed to set the client address with X-Forwarded-For. That address keys the API rate limit (100 requests a minute) and appears in request logs. Unset means the TCP peer is used, so set this when tsflow runs behind a reverse proxy or ingress. Header access mode uses TSFLOW_ACCESS_TRUSTED_PROXIES when this is unset. -
TSFLOW_MCP_ENABLED Serve a read-only MCP endpoint at /mcp. Off unless set to true or 1. false
TSFLOW_LOG_LEVEL debug logs every denied request instead of one line per peer and reason a minute. -

tsnet Serve Mode

TSFlow can embed a Tailscale node and serve itself directly on your tailnet, eliminating the need for a separate Tailscale sidecar container.

Variable Description Default
TSFLOW_SERVE Enable tsnet serve mode false
TSFLOW_HOSTNAME MagicDNS hostname on the tailnet tsflow
TSFLOW_TAGS Comma-separated ACL tags (e.g. tag:tsflow) -
TSFLOW_FUNNEL Expose via Tailscale Funnel. Refused when more than one tailnet is configured. false
TSFLOW_STATE_DIR tsnet state persistence directory ./data/tsnet-state
TSFLOW_HEALTH_PORT Also serve GET /health, and nothing else, on this plain port. For Kubernetes probes, which cannot reach the tailnet. Only valid with TSFLOW_SERVE=true. -
Workload Identity Federation

tsnet mode supports workload identity federation as an alternative to OAuth secrets. This lets tsflow authenticate using platform identity (AWS, GCP, GitHub Actions, Azure) without managing secrets.

Variable Description Default
TS_CLIENT_ID Federated client ID -
TS_ID_TOKEN ID token from identity provider -
TS_ID_TOKEN_FILE File holding the ID token, read once at startup. For tokens that a sidecar or init container writes. -
TS_AUDIENCE Audience for requesting platform tokens -

When TS_CLIENT_ID is set, tsflow uses WIF instead of OAuth ClientSecret for the tsnet node. The platform token is auto-detected from the runtime environment. Set exactly one of TS_ID_TOKEN, TS_ID_TOKEN_FILE, or TS_AUDIENCE. You must also set TSFLOW_TAGS.

tsnet WIF registers the embedded node only. API calls and the flow-log bucket have their own opt-in modes, described in docs/workload-identity.md. TAILSCALE_AUTH=wif is the API mode. TSFLOW_S3_AUTH=aws_default is the bucket mode. Leaving both unset keeps OAuth, API key, and static S3 keys.

Requirements:

  • OAuth credentials or workload identity federation (API keys are not supported in tsnet mode)
  • ACL tags must be allowed for the OAuth client or federated identity to register nodes
  • For Funnel, the ACL must grant funnel access to the tag

tsnet mode serves on both port 80 (HTTP) and port 443 (HTTPS).

Example with OAuth:

docker run -d \
  --name tsflow \
  -v tsflow_data:/app/data \
  -e TAILSCALE_OAUTH_CLIENT_ID=your-client-id \
  -e TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret \
  -e TSFLOW_SERVE=true \
  -e TSFLOW_HOSTNAME=tsflow \
  -e TSFLOW_TAGS=tag:tsflow \
  ghcr.io/rajsinghtech/tsflow:latest

Example with Workload Identity (GCP):

docker run -d \
  --name tsflow \
  -v tsflow_data:/app/data \
  -e TAILSCALE_OAUTH_CLIENT_ID=your-client-id \
  -e TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret \
  -e TSFLOW_SERVE=true \
  -e TSFLOW_HOSTNAME=tsflow \
  -e TSFLOW_TAGS=tag:tsflow \
  -e TS_CLIENT_ID=your-federated-client-id \
  -e TS_AUDIENCE=your-tailnet.org \
  ghcr.io/rajsinghtech/tsflow:latest

TSFlow will be accessible at both https://tsflow.<your-tailnet>.ts.net and http://tsflow.<your-tailnet>.ts.net.

Internal access control

Access control is off unless you set it. With the variables below unset, every request behaves as it does today, including GET /api/tailnets and the data routes. Health checks stay open either way: GET /health and GET /api/health.

When it is on, a viewer needs a Tailscale application capability, or membership in a mapped group, unless you set TSFLOW_ACCESS_GRANTS=identity. That setting is explicit: any resolved WhoIs identity, or a Tailscale-User-Login header from a trusted proxy, can see every configured tailnet, and no identity is 403. whoami and autoscope still work. Unset, or required, still requires a capability or a group grant, including when TSFLOW_ACCESS_MODE is set. The capability name is TSFLOW_ACCESS_CAPABILITY. The example below uses example.com/cap/tsflow. Each grant is a JSON object. The only field is optional:

{"tailnets": ["default", "lab"]}

Omit tailnets, or include "*", to allow every configured tailnet id. Those ids are the ones from TSFLOW_TAILNETS_FILE (default when you use the single-tailnet environment variables). A request with no matching capability and no mapped group is 403 with missing access grant. GET /api/tailnets lists only the tailnets that grant allows. Other data routes return 403 for a tailnet the grant does not include. If several grants match, the tailnet lists are unioned. One grant that allows every tailnet allows every tailnet.

Identity is logged per request only when TSFLOW_LOG_LEVEL=debug.

A denied request always logs one line with the reason, the TCP peer (peer=, the r.RemoteAddr that trusted-proxy checks use), the raw X-Forwarded-For value, and in header mode whether the peer matched TSFLOW_ACCESS_TRUSTED_PROXIES (trusted_proxy=). The client IP in the request log comes from X-Forwarded-For only when the peer is a trusted proxy (TSFLOW_TRUSTED_PROXIES), so it can differ from the peer. Each peer and reason pair is logged at most once a minute, or on every request with TSFLOW_LOG_LEVEL=debug. Identity headers are not logged.

There are two front doors.

tsnet. TSFLOW_SERVE=true with TSFLOW_ACCESS_CAPABILITY set. tsflow is already a node on the tailnet. Each request calls LocalAPI WhoIs on the connection's remote address and reads that peer's capability map. You can leave TSFLOW_ACCESS_MODE unset. Set it to tsnet if you want to be explicit. TSFLOW_SERVE must be true for that mode. Do not enable Funnel for this, because Funnel traffic has no tailnet identity and those requests are denied.

Example grant in the tailnet policy file:

{
  "grants": [
    {
      "src": ["group:eng"],
      "dst": ["tag:tsflow"],
      "app": {
        "example.com/cap/tsflow": [
          {"tailnets": ["default", "lab"]}
        ]
      }
    },
    {
      "src": ["group:ops"],
      "dst": ["tag:tsflow"],
      "app": {
        "example.com/cap/tsflow": [{}]
      }
    }
  ]
}

The empty object allows every configured tailnet. group:ops can see all of them. group:eng can see default and lab.

Reverse proxy. Use this when Tailscale Serve, or Caddy with the Tailscale plugin, already authenticated the viewer. Set TSFLOW_ACCESS_MODE=header and TSFLOW_ACCESS_TRUSTED_PROXIES to the proxy CIDRs. Identity headers are trusted only from those CIDRs. If the proxy dials localhost over IPv6, include ::1/128 next to 127.0.0.1/32. The same rule applies to the groups header. A client that is not in the list cannot supply Tailscale-User-Login, Tailscale-User-Name, the capability header, or the groups header. Those values are ignored, and the request is denied.

Tailscale Serve sends Tailscale-User-Login and Tailscale-User-Name. If your proxy sends identity under other names, set TSFLOW_ACCESS_USER_HEADER (login, for example X-Tailscale-User) and TSFLOW_ACCESS_NAME_HEADER (display name, for example X-Tailscale-Name). Point them at headers the proxy always overwrites, never at one a client can pass through. A custom header replaces the default for that field, and the default name is not read as a fallback. To also send capabilities, pass --accept-app-caps with the same capability name. Serve puts them in Tailscale-App-Capabilities. Override that name with TSFLOW_ACCESS_CAPABILITY_HEADER if your proxy uses another header.

tailscale serve --accept-app-caps=example.com/cap/tsflow --https=443 http://127.0.0.1:8080
TSFLOW_ACCESS_MODE=header
TSFLOW_ACCESS_CAPABILITY=example.com/cap/tsflow
TSFLOW_ACCESS_TRUSTED_PROXIES=127.0.0.1/32

If a local tailscaled socket is reachable, header mode calls WhoIs on the X-Forwarded-For peer instead of trusting the identity and capability headers. The right-most forwarded address, across every X-Forwarded-For line, is the peer. If that entry is not an IP address the request is denied. Set TSFLOW_ACCESS_LOCAL_WHOIS=off to always trust headers. Set it to require, or set TSFLOW_ACCESS_TAILSCALED_SOCKET, when a missing socket should stop startup. The default is auto: use WhoIs when the socket answers, and headers when it does not.

Some proxies forward identity and a groups header, and do not forward app capabilities. Set TSFLOW_ACCESS_GROUPS_HEADER to that header name. Values are comma-separated and must match the keys in the grant map exactly. Load the map from TSFLOW_ACCESS_GROUP_GRANTS or TSFLOW_ACCESS_GROUP_GRANTS_FILE, not both. The values use the same grant object as the capability. A mapped group grants access. If a request has both a capability and mapped groups, the tailnet lists are unioned.

{"group:eng": {"tailnets": ["default"]}, "group:ops": {}, "ops@example.com": {"tailnets": ["lab"]}}

Caddy, using the Tailscale plugin for identity. tailscale_user is the full login and tailscale_name is the display name. If the proxy also sends a groups header, forward that header under the name you set in TSFLOW_ACCESS_GROUPS_HEADER.

{
  order tailscale_auth before reverse_proxy
}

:443 {
  bind tailscale/tsflow
  tls {
    get_certificate tailscale
  }
  tailscale_auth
  reverse_proxy 127.0.0.1:8080 {
    header_up Tailscale-User-Login {http.auth.user.tailscale_user}
    header_up Tailscale-User-Name {http.auth.user.tailscale_name}
  }
}
TSFLOW_ACCESS_MODE=header
TSFLOW_ACCESS_TRUSTED_PROXIES=127.0.0.1/32
TSFLOW_ACCESS_GROUPS_HEADER=X-Tsflow-Groups
TSFLOW_ACCESS_GROUP_GRANTS={"group:eng":{"tailnets":["default"]},"group:ops":{}}

On Kubernetes, keep the Service reachable only from the proxy, and set the trusted CIDR to that proxy range. The default manifests in k8s/ do not enable this. k8s/access-example.yaml is a starting point:

env:
  - name: TSFLOW_ACCESS_MODE
    value: header
  - name: TSFLOW_ACCESS_CAPABILITY
    value: example.com/cap/tsflow
  - name: TSFLOW_ACCESS_TRUSTED_PROXIES
    value: 10.0.0.0/8
  - name: TSFLOW_ACCESS_GROUPS_HEADER
    value: X-Tsflow-Groups
  - name: TSFLOW_ACCESS_GROUP_GRANTS
    value: '{"group:eng":{"tailnets":["default"]}}'
  - name: TSFLOW_ACCESS_AUTOSCOPE
    value: user

Startup fails when the settings disagree. Header mode without trusted CIDRs is rejected. A capability with neither TSFLOW_SERVE nor header mode is rejected. A groups header without a grant map is rejected.

GET /api/whoami returns the viewer when a grant matched. With access control off it returns {"autoscope":"off"}.

TSFLOW_ACCESS_AUTOSCOPE is off by default. user preselects devices whose user matches the viewer login. groups preselects devices for the mapped groups the viewer is in: group:eng and eng match tag tag:eng, and a mapping key that contains @ matches devices owned by that login. This is only the initial traffic view. The filter panel has a My devices chip with Clear. Clearing it shows the full view. The API does not enforce the device filter.

Data Storage & Polling

Variable Description Default
TSFLOW_DB_PATH SQLite database path ./data/tsflow.db
TSFLOW_SKIP_DB_BACKUP Skip the pre-migration database copy. Set 1 only when disk space is tight. unset
TSFLOW_POLL_INTERVAL How often to import new flow logs 5m
TSFLOW_INITIAL_BACKFILL How far back to fetch logs on startup 6h
TSFLOW_POLL_DELAY API backend only. Each poll fetches logs up to now minus this delay, because the logs API can publish a message a little after its logged time and a window is never re-read. Set 0 to poll up to now. S3 and GCS polling use TSFLOW_S3_LOOKBACK instead. 2m
TSFLOW_GRAPH_CACHE_MB Memory cap, in MiB, for the cache of closed hours that the graph view reads. Each cached hour stays until a late write or retention changes it, so wide live windows only read the current hour from disk. Each hour takes about 0.5 MiB at 1k nodes, 2.3 MiB at 5k and 9 MiB at 20k, so the default holds a full day at 20k nodes. Least-recently-used hours are evicted at the cap. Set 0 to disable. 256
TSFLOW_RETENTION How long to keep flow data. Set 0 to disable cleanup. 720h for API mode, disabled for S3 mode
TSFLOW_FLOW_BACKEND Flow backend: api, s3, or gcs api
TSFLOW_S3_AUTH static, aws_default, or gcs_adc static
TSFLOW_S3_ROLE_ARN Optional role to assume when auth is aws_default. With a web identity token file this is AssumeRoleWithWebIdentity. -
TSFLOW_S3_WEB_IDENTITY_TOKEN_FILE OIDC token file used to assume TSFLOW_S3_ROLE_ARN. A tailnets entry can set its own file. -
TSFLOW_S3_BUCKET S3/Garage bucket containing exported flow logs tailscale-logs
TSFLOW_S3_PREFIX Object prefix for network flow objects network/
TSFLOW_S3_ENDPOINT S3-compatible endpoint URL -
TSFLOW_S3_REGION S3 region. aws_default uses the SDK region. garage
TSFLOW_S3_ACCESS_KEY_ID S3 access key ID -
TSFLOW_S3_SECRET_ACCESS_KEY S3 secret access key -
TSFLOW_S3_PATH_STYLE Path-style bucket addressing true, false with aws_default
TSFLOW_S3_LOOKBACK Object-store listing overlap for late objects 15m
TSFLOW_S3_MAX_OBJECTS_PER_POLL Max objects imported per poll cycle 500

The older S3 names still work when the TSFLOW_S3_* name is unset: TAILSCALE_LOGS_S3_*, AWS_ENDPOINT_URL, AWS_REGION, AWS_DEFAULT_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and lower-case endpoint and region. With no backend set, an endpoint plus keys still means s3. Every variable also accepts a VITE_ prefix. See docs/environment.md.

Data Storage

TSFlow stores per-minute flow aggregates in SQLite with a rolling retention window (default 30 days). Charts over wider windows use query-time bucketing — no data loss from pre-aggregation. When TSFLOW_FLOW_BACKEND=s3, TSFlow imports immutable network/YYYY/MM/DD/*.ndjson, *.ndjson.zst, *.ndjson.zstd, *.ndjson.gz, or *.ndjson.gzip objects from S3-compatible storage and tracks ingested object keys so repeated polling does not double count traffic.

Ranked talkers and pairs

GET /api/analytics/talkers and GET /api/analytics/pairs return JSON rankings for a time window. The Analytics page's Top Talkers and Top Pairs tables use them, for the page's window and traffic-type filter, with previous and next pages, a bytes or flows sort, and a search box. Clicking a device name opens it on the traffic graph over the same window. The /api/stats/top-talkers and /api/stats/top-pairs routes stay for API callers.

start and end are RFC3339. limit defaults to 20 and stops at 200. offset defaults to 0. sort is bytes (total volume, the default) or flows. On a single-tailnet install the tailnet parameter can be omitted. With several tailnets, pass the same id the other data routes use. Optional q narrows both rankings before paging, with the same rules as the traffic graph search: tag:x, ip:x, user@x for the owner login (including the creator login recorded for a tagged device), or a case-insensitive substring of a device name, owner email, address, or tag. A pair matches when either end does.

A talker is one device. The row has nodeId, hostname, owner, txBytes, rxBytes, totalBytes, and flowCount. nodeId is the canonical device id, and rows stored under another id for the same device (a legacy numeric id or an address) are merged into it on the page. hostname and owner come from the device list, falling back to the stored flow-log name; 127.3.3.40 is labeled DERP relay. A pair row has srcNodeId, srcHostname, srcOwner, dstNodeId, dstHostname, dstOwner, the same byte fields, and flowCount. Physical (WireGuard transport) traffic is left out unless trafficTypes includes physical. Rows are ordered by the sort field descending, then by id. metadata.hasMore is true when a later page exists.

An hour that sits fully inside the window is read from the hourly rollup (node_pair_hours). The partial hour at each end is read from minute rows in node_pairs. When no hour is rolled up yet, the read uses minute rows. An empty window returns an empty list. Optional trafficTypes uses the same values as the other stats routes (virtual, subnet, exit, physical).

New connections

GET /api/analytics/new-pairs lists directed pairs that appear in the selected window and do not appear in the lookback immediately before it. lookback defaults to 7d. It also accepts 24h, 30d, or any duration from 1h through 90d. limit, offset, start, end, tailnet, and trafficTypes match the ranked pair route. Rows are ordered by first seen, newest first, then by volume. There is no alert. The page is /new.

metadata.lookbackStart is where the lookback begins. metadata.dataStart is the earliest stored minute and is left out when nothing is stored. metadata.lookbackComplete is false when stored data starts after lookbackStart. A pair last seen before the data starts then shows up as new, and the page says so.

Physical traffic is left out unless trafficTypes includes physical. A DERP address is labeled DERP relay. Complete hours are read from node_pair_hours.

Device timeline

GET /api/analytics/device-timeline returns one device's bytes over time, split into virtual, subnet, and exit, plus one page of its peers. Pass node (the stored device id). limit and offset page the peers, not the time buckets. Windows of two hours or less use one-minute buckets. Longer windows use hourly buckets, and complete hours are read from node_pair_hours.

physical is omitted unless trafficTypes includes it. Each bucket includes seconds, the part of the bucket inside the query window. A DERP peer is labeled DERP relay. Selecting a device on the traffic graph opens the same timeline.

Me

GET /api/analytics/me is the signed-in viewer's devices, traffic, per-device timelines, top peers, and new connections. The login is the request identity from WhoIs or the trusted proxy headers. No request parameter sets it. me=1 on the talker, pair, new-pair, and device-timeline routes uses that same login and compares it in full, including a tagged device's creator login. On the talker and pair routes, q then narrows the viewer's own devices. Devices are listed busiest first. Top peers leave out the viewer's own devices and peers with no bytes. New connections leave out a device talking to itself, but keep one between two of the viewer's devices. The response metadata carries the same lookbackStart, dataStart, and lookbackComplete fields as /api/analytics/new-pairs for newPairs. Without a login the route is unauthorized, and the Me tab is not shown. When a login is known, / opens Me unless the address has query parameters or the viewer already chose another tab. That choice is stored in tsflow-last-tab.

MCP server

TSFLOW_MCP_ENABLED=true serves a read-only Model Context Protocol endpoint at /mcp on the same HTTP server as the UI, so Claude, Cursor and other MCP clients can ask questions about your flow data. The route is absent when the variable is unset. It uses streamable HTTP and the same access middleware as /api: trusted-proxy and WhoIs identity, then the capability or group grant and its optional tailnet allowlist. A viewer cannot query a tailnet outside that allowlist. There is no separate MCP credential.

Turn it on

Add the variable to the container you already run:

docker run -d \
  --name tsflow \
  -p 127.0.0.1:8080:8080 \
  -v tsflow_data:/app/data \
  -e TAILSCALE_OAUTH_CLIENT_ID=your-client-id \
  -e TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret \
  -e TSFLOW_MCP_ENABLED=true \
  ghcr.io/rajsinghtech/tsflow:latest

The endpoint is then http://localhost:8080/mcp. With TSFLOW_SERVE=true it is https://<TSFLOW_HOSTNAME>.<your-tailnet>.ts.net/mcp. With header access on, use the trusted proxy's URL so the proxy sets the identity headers. A client should never send Tailscale-User-Login itself.

On Kubernetes (the repository has manifests, not a Helm chart), set it on the container:

env:
  - name: TSFLOW_MCP_ENABLED
    value: "true"

k8s/deployment.yaml leaves it false. k8s/access-example.yaml shows it next to the access settings. Keep the Service reachable only from the tailnet or the trusted proxy.

Connect a client

The examples use https://tsflow.example.ts.net/mcp. Swap in your own URL.

Claude Code:

claude mcp add --transport http tsflow https://tsflow.example.ts.net/mcp

Or commit it to a project's .mcp.json:

{
  "mcpServers": {
    "tsflow": {
      "type": "http",
      "url": "https://tsflow.example.ts.net/mcp"
    }
  }
}

Cursor, in ~/.cursor/mcp.json or a project's .cursor/mcp.json:

{
  "mcpServers": {
    "tsflow": {
      "url": "https://tsflow.example.ts.net/mcp"
    }
  }
}

Claude Desktop's claude_desktop_config.json only starts local servers, so bridge to the URL with mcp-remote (needs Node 18 or newer):

{
  "mcpServers": {
    "tsflow": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://tsflow.example.ts.net/mcp"]
    }
  }
}

Claude's "Add custom connector" setting connects from Anthropic's cloud rather than from your machine, so it can't reach a tsflow that only listens on your tailnet or on localhost. Use the bridge above instead.

Tools

Tool Answers
whoami Who the tools run as: login, groups, permitted tailnets, and what scope=mine keeps
list_tailnets Which tailnets you can query, with poller status
search_devices Find devices by name, hostname, IP, tag, or owner login
get_device One device by id, name, or IP, with tags and owner
top_talkers Busiest devices over a window, by bytes or flows
top_pairs Busiest device pairs over a window
flows_between Bytes, protocols, and ports between two devices, IPs, or CIDRs
device_peers Who one device talks to most
device_timeline One device's bytes over time, split by traffic type
new_connections Pairs first seen in the window and absent from the lookback before it
stats_overview Network-wide protocol, traffic-type, flow, and pair totals

Every tool is marked read-only and reads stored rollups. start and end take RFC3339 (2026-01-02T15:04:05Z) or a time relative to now (now-2h, 2h ago, last 24h, last week). Windows default to the last hour and stop at 7 days. lookback takes durations like 24h, 3d, or 1w, up to 7 days. Lists default to 20 rows, stop at 100 per page, and page with offset up to 1000. Physical transport is excluded unless trafficTypes includes physical. DERP relays are labeled DERP relay. Pass tailnet when more than one tailnet is configured.

Identity autoscope is only the initial device view, the same way the UI starts. Device tools take scope: mine or all. When autoscope is user or groups, the default is mine and results match that filter. scope=all clears it and returns every device in the permitted tailnets. list_tailnets and stats_overview stay tailnet-wide, matching the REST routes.

Things to ask once it's connected: "what were the top talkers in the last 2h?", "who has db-01 been talking to today?", "any new connections since this morning?"

Security notes

  • MCP is off by default. With it off, /mcp doesn't exist.
  • The tools can't change anything in tsflow or Tailscale, and no tool returns credentials or API keys.
  • With access control off, anyone who can reach /mcp can read every tailnet's flow data, the same as the UI. Keep tsflow on your tailnet or bound to localhost, as in the example above.
  • With access control on, MCP uses the same identity, grants, and tailnet allowlists as /api. A viewer can't query a tailnet they aren't granted.
  • scope=mine is a convenience view, not a boundary. scope=all and the REST API show every device in the tailnets a viewer is granted. To keep people apart, give them different tailnet grants.
  • /mcp has its own rate limit of 100 requests a minute per client address. Over the limit it returns 429 with Retry-After. Behind a proxy, set TSFLOW_TRUSTED_PROXIES so the limit counts the real client and not the proxy.
  • Whatever a tool returns (hostnames, owner logins, tags, traffic volumes) goes to the model your client uses. Device names and tags come from your tailnet, so treat them as untrusted text.
  • The health port used with TSFLOW_SERVE does not expose /mcp.

Raw flow-log endpoints are deprecated because raw events are not retained: use /api/flow-logs/aggregated for historical traffic. The legacy /api/flow-logs and /api/devices/:deviceId/flows routes return 410 Gone with the replacement endpoint.

Mount a volume to persist data: -v tsflow_data:/app/data

Upgrading from 1.6

Your 1.6 config works as is. No variable was renamed or removed, no default changed, and nothing new is required. A table test in backend/internal/config runs every 1.6.2 config shape against the current loader to keep it that way. With no TSFLOW_TAILNETS_FILE, the single-tailnet variables work as before and the data is stored as tailnet default. MCP, access control, several tailnets and the health port stay off until you set them.

On first start tsflow migrates the database. It writes a backup first (see below), tags your existing rows as tailnet default, and builds the hourly rollups. Old data stays visible and S3 objects that were already imported aren't read again.

Things that behave a bit differently:

  • Physical traffic (the underlay, including DERP) is left out of totals unless you pick it in the traffic filter or pass trafficTypes=physical. Totals can look lower than in 1.6.
  • /api/network-logs serves at most 30 minutes per request and returns 410 for longer windows. limit defaults to 1000 and caps at 5000. Use the stats and analytics routes for longer ranges.
  • Device id in API responses is the stable node id (n...) instead of the numeric id. Numeric ids still work in queries.
  • The rate limit (100 requests a minute) is keyed by the TCP peer. 1.6 trusted X-Forwarded-For from anyone. Behind a reverse proxy, set TSFLOW_TRUSTED_PROXIES to the proxy address so each client gets its own limit again.
  • In development mode, CORS only allows loopback origins unless ALLOWED_CORS_ORIGINS is set. It no longer echoes any origin.
  • API polls stop 2 minutes behind now (TSFLOW_POLL_DELAY), so late log messages aren't missed. The newest 2 minutes show up one poll later.

Rolling back to the previous release

This release changes the SQLite schema. Before it does, startup writes a full copy of the database beside the live file. For /app/data/tsflow.db the copy is /app/data/tsflow.db.pre-tailnet. The log names that path. The copy needs about as much free disk as the database file, on top of the temporary space the migration uses while it rewrites tables.

The previous release cannot write the migrated file. Its inserts target the old primary keys, and it reads the poll cursor from poll_state.id = 1, which the new schema does not have. To run that release again:

  1. Stop tsflow.
  2. Replace the live database with the backup. For the path above, move tsflow.db.pre-tailnet to tsflow.db.
  3. Delete tsflow.db-wal and tsflow.db-shm if they exist, so the restored file is not opened with a write-ahead log from the new process.
  4. Start the previous release.

Rows saved after the upgrade are not in the backup. If startup cannot write the copy, it stops before changing the schema. Set TSFLOW_SKIP_DB_BACKUP=1 to migrate without a rollback copy. The log says when that happens. Keep a backup of your own if you still want a way back.

Development

Setup

You need Go (see backend/go.mod) and Node 22 or newer. The frontend build fails on Node 20.

git clone https://github.com/rajsinghtech/tsflow.git
cd tsflow

# Install dependencies
cd frontend && npm install && cd ..
cd backend && go mod download && cd ..

Development Mode

Run backend and frontend separately for hot reload:

# Terminal 1: Backend (no embedded frontend)
make dev-backend

# Terminal 2: Frontend with Vite dev server
make dev-frontend

Frontend runs on http://localhost:5173 and proxies /api to backend on :8080.

Production Build

make build
./backend/tsflow

This builds the SvelteKit frontend and embeds it in the Go binary.

Deployment

Docker Compose

services:
  tsflow:
    image: ghcr.io/rajsinghtech/tsflow:latest
    ports:
      - "8080:8080"
    environment:
      - TAILSCALE_OAUTH_CLIENT_ID=${TAILSCALE_OAUTH_CLIENT_ID}
      - TAILSCALE_OAUTH_CLIENT_SECRET=${TAILSCALE_OAUTH_CLIENT_SECRET}
    volumes:
      - tsflow_data:/app/data
    restart: unless-stopped

volumes:
  tsflow_data:

Kubernetes

cd k8s
# Requires envsubst (provided by gettext). Export the variables referenced by
# secret.yaml, then expand the template before applying the rendered manifests.
kubectl kustomize . | envsubst | kubectl apply -f -

Alternatively, replace the ${...} placeholders in k8s/secret.yaml with your credentials and run kubectl apply -k k8s directly. Do not apply the template unchanged: Kubernetes does not expand shell-style environment variables in manifests.

Changelog

See CHANGELOG.md.

Star History

Star History Chart

License

MIT


Built with ❤️ for the Tailscale community

About

Network flow visualizer for Tailscale - Requires Premium TS plans

Topics

Resources

Stars

201 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages