Skip to content
ynuiPublic

About

Expose local HTTP apps through stable public URLs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

inlet

Your localhost, now a URL.

Share a local HTTP app with a stable public link. No port forwarding, router changes, or deployment required.

inlet terminal forwarding a local app

inlet login
inlet http 3000

Get a client from GitHub Releases, or jump to the local server setup.

Start your local app on port 3000 before opening the tunnel.

How it works

browser ──► https://tunnel.example.com/<tunnel-id>/...
                              │
                        inletd server :8080
                              │
                        inlet client ──► http://127.0.0.1:3000

The client keeps one WebSocket connection to the server. Requests to the tunnel's public URL travel through it to your local app; the first URL path segment identifies the tunnel.

The terminal shows connection status and recent requests. The local inspector (http://127.0.0.1:4040) keeps the last 200 requests, including headers, bodies, and timings:

inlet inspector

Requirements

  • Go 1.23+ (no CGO for SQLite; gorilla/websocket, modernc.org/sqlite, plus jackc/pgx and go-sql-driver/mysql for external databases)
  • Docker + Compose (optional, for deployment)

Install the client

Shell script (macOS & Linux)

curl -fsSL https://raw.githubusercontent.com/ynui/inlet/main/install.sh | bash

Homebrew (macOS & Linux)

brew install ynui/tap/inlet

Manual download

Download the client archive for your OS and CPU from GitHub Releases. Extract it and put the inlet binary somewhere on your PATH. Releases include Linux and macOS builds for amd64/arm64 and Windows amd64 builds. Client archives contain only inlet; separate inletd archives are published for self-hosters, with checksums in the versioned checksum file.

You do not need Go installed to use a release binary.

Release clients and binaries built with make build default to the public demo at wss://inletd.onrender.com. go run ./cmd/inlet uses localhost for development, and make dev configures its client for the local server. Change the saved server at any time with inlet config set-server <url>.

The public demo supports both GitHub device login and open signup. Use inlet login to link device tokens to your GitHub account; it opens GitHub in your browser and continues in the terminal after approval. Or use inlet signup for a token managed under the legacy per-IP limits. GitHub credentials are not saved by inlet.

To build from source instead, clone the repository and run make build. Remote server administration lives in the client binary as inlet admin.

Quick start (local)

make build         # produces ./inlet and ./inletd

All state lives in one local SQLite DB (./data/inlet.db, in the directory you start inletd from). Admin commands (invite, token) write to that DB directly — run them from that same directory, with the server stopped or running.

You can point the server at an external database instead by setting INLET_DB to a DSN/URL. Supported formats:

# SQLite file (default) — plain path or explicit scheme
INLET_DB=./data/inlet.db
INLET_DB=sqlite:///absolute/path/inlet.db

# PostgreSQL
INLET_DB='postgres://user:password@localhost:5432/inlet?sslmode=require'

# MySQL
INLET_DB='mysql://user:password@tcp(localhost:3306)/inlet'

The schema is created and migrated automatically on startup for every backend. For PostgreSQL and MySQL the database must already exist and the user must have DDL rights. Add ?sslmode=require (Postgres) or use the driver's TLS options (MySQL) in production; the DSN is passed through to the driver unchanged. The configured password is masked in startup logs.

1. Create an invite (or a token)

./inletd invite create --ttl 24h --label me   # prints a one-time code
./inletd token create --label me              # alternative: token straight away

Invites are hashed, single-use, and expire. The code is only printed once. --ttl defaults to INLET_INVITE_TTL (24h).

2. Start the server

INLET_PUBLIC_BASE=http://localhost:8080 ./inletd
# public, control, and signup on :8080

INLET_PUBLIC_BASE is the public URL clients print. Set it to the HTTPS hostname served by your reverse proxy. Without it, the server derives the URL from the control request and forwarded headers.

When using the hosted Render service, free instances may sleep after inactivity and their local filesystem is ephemeral. Since inlet stores tokens and tunnel IDs in SQLite by default, either use durable storage, back up the database, or point INLET_DB at an external Postgres/MySQL instance before relying on that service for persistent accounts.

Invite-only is the default. For open signup:

INLET_OPEN_SIGNUP=true ./inletd

The server loads ./.env from its working directory. Keep INLET_OPEN_SIGNUP=false (or unset) for invite-only signup.

3. Claim the invite on the client

./inlet signup --invite <code>     # saves the token to ~/.config/inlet/config.json
./inlet signup --server ws://localhost:8080 --invite <code>   # explicit server
./inlet signup                     # open signup only (when GitHub login is not configured)
./inlet login                      # GitHub device login (when configured by the server)

Source builds default to ws://localhost:8080; release binaries default to wss://inletd.onrender.com. Pass --server to override the default. Trying to reuse an invite fails with 403 invalid or expired invite.

After a successful claim, the client confirms that the token was saved, shows the selected server and public URL, and prints inlet http <local-port> as the next step. The token itself is not printed. The server URL is saved with the token, so future inlet http runs use the same server automatically.

Example:

invite claimed successfully
token saved to: ~/.config/inlet/config.json
server: wss://tunnel.example.com
public url: https://tunnel.example.com/<tunnel-id>
run: inlet http <local-port>

4. Tunnel something

go run ./scripts/demo-server       # test target on :8000, in another shell
./inlet http 8000                  # prints the public URL

5. Hit it

curl http://localhost:8080/<id>/   # <id> from the printed URL
open http://127.0.0.1:4040         # local inspector, last 200 requests

Manage tokens/invites while it runs:

./inletd token list                # prefix, source, tunnel ID, last used, state
./inletd token revoke <prefix>
./inletd invite create --ttl 1h

make dev / scripts/dev.sh start does steps 1–2 for you (builds, creates a dev and dev-admin tokens, configures local client/admin credentials, and starts inletd + the demo server); then run ./inlet http 8000 and stop with make dev-stop.

The same tunnel ID is reused on every reconnect for that token. --new-url on inlet http rotates it.

Client

inlet login --server https://tunnel.example.com # GitHub device login
inlet signup --server ws://localhost:8080       # open signup, or --invite <code>
inlet config set-server wss://tunnel.example.com
inlet config show                          # token value is hidden
inlet doctor [--port 3000]                 # check server and optional local app
inlet auth <token>                         # save an existing token
inlet token list                           # list tokens for your GitHub account (or legacy signup IP)
inlet token label <prefix> "work laptop"   # rename a listed token
inlet token revoke <prefix>                # revoke a listed token
inlet http <port>                         # tunnel a local HTTP server
inlet http <port> --new-url                # rotate the tunnel ID
inlet http <port> --inspect 4041           # inspector port (default 4040)

With GitHub login, inlet token list shows all tokens for that GitHub account, so you can label or revoke individual devices. Legacy open-signup tokens remain manageable by signup IP. Labels can be up to 64 characters; use "" to clear one.

Flags work before or after the port. While running, the terminal shows a live status pane (last 10 requests, connection status and errors, 2xx–5xx counts). Ctrl-C exits and restores the terminal. The inspector at http://127.0.0.1:<inspectPort> keeps the last 200 requests and is bound to loopback only.

The server URL is saved in the client config. inlet signup --server … sets it during signup, or change it later with inlet config set-server <url>. inlet auth and inlet http use the saved URL:

// ~/.config/inlet/config.json
{ "server": "wss://inletd.onrender.com", "token": "…", "inspectPort": 4040 }

Use ws:// for a local HTTP server and wss:// for a TLS reverse proxy. If the URL has no path, the client appends /_inlet/control automatically. inlet config set-server https://tunnel.example.com is also accepted and saved as wss://tunnel.example.com.

Server

inletd [-bind :8080] [-control <addr>]

inletd ships as its own archive in each release and can be installed with go install github.com/ynui/inlet/cmd/inletd@latest (and go install github.com/ynui/inlet/cmd/inlet@latest for the client). Binaries built with go install have no release ldflags, so they default to the local server (ws://localhost:8080) instead of the public demo. Docker remains the recommended deployment path (see Docker).

By default, public tunnel traffic, the WebSocket control channel, and signup share the listener at :8080. Set -control :8081 (or INLET_CONTROL_ADDR=:8081) to run control and signup on a separate listener.

Configuration precedence: flag defaults → ~/.config/inlet/server.json → explicit CLI flags → environment. An open-signup value changed with inlet admin is stored in the database and takes precedence over startup config after the first remote change.

Env Meaning Default
INLET_BIND public HTTP listener :8080
INLET_CONTROL_ADDR optional separate control/signup listener unset (shares INLET_BIND)
INLET_PUBLIC_BASE public base URL advertised to clients (e.g. https://example.com) derived from control request
INLET_OPEN_SIGNUP allow token creation without an invite false
INLET_GITHUB_CLIENT_ID GitHub OAuth App client ID; enable Device Flow in the app unset
INLET_GITHUB_CLIENT_SECRET GitHub OAuth App secret used only for server-side token validation unset
INLET_ADMIN_TOKEN bootstrap a remote admin token; stored hashed in the database unset
INLET_ACCESS_LOG log public request method, tunnel ID, status and duration false
INLET_DB Database DSN/path. SQLite path (default), or sqlite://, postgres:///postgresql://, mysql:// URLs ./data/inlet.db
INLET_STALE_AFTER delete inactive invite/admin tokens after 720h
INLET_OPEN_STALE_AFTER delete inactive open-signup tokens after 168h
INLET_REVOKED_RETENTION keep revoked tokens before deleting 168h
INLET_INVITE_TTL default invite lifetime 24h

Set both GitHub variables to enable inlet login. New GitHub accounts are allowed only while open signup is enabled; existing accounts can add device tokens after signup is closed. Without GitHub configuration, invite and open signup behavior is unchanged. Environment values override server.json at startup. After a remote inlet admin change, the persisted open-signup setting overrides both. The server also loads ./.env at startup without overwriting variables that are already exported, so make run-server works after cp .env.example .env. Lifecycle and error logs are always enabled. Set INLET_ACCESS_LOG=true to log successful public requests too; access logs omit paths, query strings, headers, bodies and client addresses.

Remote administration

Bootstrap an admin token without opening a server shell by setting a random INLET_ADMIN_TOKEN secret in the deployment environment, then redeploying. The server stores only its hash in the database; the token is labeled admin-token in listings. Alternatively, create one locally:

inletd token create --label remote-admin

The remote admin CLI ships inside the regular client binary. Save the token and server URL on your workstation, then run operator commands with inlet admin. Use the HTTPS URL for remote administration:

inlet admin auth --server https://tunnel.example.com <admin-token>
inlet admin invite create --ttl 24h --label teammate
inlet admin config show
inlet admin config set open-signup true
inlet admin token list --limit 50 --offset 0
inlet admin token create --label laptop
inlet admin token label <prefix> "work laptop"
inlet admin token revoke <prefix> [<prefix> ...]

inlet token manages your own client token; inlet admin token manages every token on the server; inletd token edits the configured database directly.

The admin token is stored separately in ~/.config/inlet/admin.json; regular inlet commands never read or send it — only inlet admin does. Remote admin requests accept only tokens created as admin tokens and require HTTPS, except for loopback development. The signup setting persists in the database. Set INLET_OPEN_SIGNUP or server.json to choose its initial value; once changed remotely, the saved admin setting wins after restarts.

Token listings show metadata and prefixes only. token create prints a new client token once; use token create --admin only when provisioning another remote administrator. Revoke one or more prefixes in a single transaction. Revoking the admin token used for the request is blocked to prevent accidental lockout; authenticate with another admin token first.

server.json mirrors these keys. For example: {"bind": ":8080", "controlAddr": "", "publicBase": "…", "openSignup": false}. Set controlAddr to an address such as :8081 to enable a separate control listener. A legacy non-empty controlAddr keeps that listener enabled; startup logs a warning. Clear it with -control= or INLET_CONTROL_ADDR= to return to one listener.

Tokens are stored as SHA-256 hashes in the database, and each token row owns its tunnel ID. Create client tokens with inletd token create, claim an invite, or use inlet admin to manage tokens remotely.

Local admin commands (inletd, run on the server or in its container):

inletd token create --label laptop   # print a token once
inletd token list
inletd token revoke <prefix>
inletd invite create --ttl 24h
inletd doctor                         # check a running server's health endpoint

A janitor removes expired invites and stale tokens hourly. Tokens with an active connection are skipped. Revocation applies to new connections immediately; an already-connected client keeps its session until it reconnects.

Docker

cp .env.example .env       # set INLET_PUBLIC_BASE to your public HTTPS hostname
docker compose up -d --build
docker compose exec inletd inletd invite create --label client

Copy the one-time invite code, then claim it on the client:

inlet signup --server wss://tunnel.example.com --invite <invite-code>

For an administrator token without an invite, use docker compose exec inletd inletd token create --label admin.

The container listens on 8080 for public, control, and signup traffic. The host port defaults to 8080; set INLET_HOST_PORT=9900 in .env if another service already uses it. Compose includes a health check at /_inlet/healthz; run docker compose ps to see the health state or docker compose exec inletd inletd doctor for a direct check. By default the database is kept in ./data/inlet.db (mounted as /data, INLET_DB=/data/inlet.db) so tokens and tunnel IDs survive restarts. To use an external database in the container, set INLET_DB to a Postgres/MySQL DSN (the SQLite mount is then unused). .env and data/ are gitignored — don't commit real tokens.

For production, terminate TLS in Nginx and proxy all paths to inlet's single listener. Example inlet.conf (adjust the hostname, certificate paths, and backend address for your setup):

server {
    listen 80;
    listen [::]:80;
    listen 443 ssl;
    listen [::]:443 ssl;

    server_name tunnel.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    include /etc/nginx/snippets/ssl-params.conf;
    include /etc/nginx/snippets/force-ssl.conf;
    include /etc/nginx/snippets/hsts-header.conf;

    access_log /var/log/nginx/access.log main_ext;

    location / {
        proxy_pass http://192.168.1.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

This uses the $connection_upgrade variable commonly defined in Nginx's http block. Set INLET_PUBLIC_BASE=https://tunnel.example.com and point clients at wss://tunnel.example.com/_inlet/control. If Nginx runs in the same Compose network, use proxy_pass http://inletd:8080; and remove the Compose ports: mapping if the backend should not be reachable directly from the host. If you set INLET_HOST_PORT=9900, set the host-based Nginx upstream to http://192.168.1.1:9900 as well.

For an existing deployment with a separate control listener, set INLET_CONTROL_ADDR=:8081, publish that port, and route /_inlet/control, /_inlet/signup, /_inlet/admin/, and /_inlet/tokens to 8081; keep public tunnel paths on 8080.

To upgrade a source checkout, pull the new version and run docker compose up -d --build. When using the default SQLite file, back it up with a brief downtime so its WAL files are included:

docker compose stop inletd
tar -czf inlet-data-backup.tgz data
docker compose start inletd

Development

go test ./...                              # all tests
go test ./cmd/inlet -run TestParseHTTPArgs
go vet ./...
make build-all                             # linux/amd64 + darwin/arm64 into dist/

The store has end-to-end tests that run against SQLite (default), PostgreSQL, and MySQL. SQLite needs no setup; external backends are enabled by DSNs and are skipped when unset. Use the bundled compose file to provide them:

docker compose -f docker-compose.test.yml up -d    # postgres:16 + mysql:8.4
INLET_TEST_DSN_SQLITE="file::memory:?cache=shared" \
INLET_TEST_DSN_POSTGRES='postgres://inlet:inlet@localhost:5432/inlet?sslmode=disable' \
INLET_TEST_DSN_MYSQL='mysql://inlet:inlet@tcp(localhost:3306)/inlet' \
  go test ./internal/store -run TestE2E -v
docker compose -f docker-compose.test.yml down

Manual end-to-end check (after inlet signup or inlet auth from Quick start):

go run ./scripts/demo-server               # test target on :8000
./inlet http 8000                          # in another shell
scripts/demo-requests.sh                   # or: scripts/demo-requests.sh http://localhost:8080/<id>

scripts/demo-server fakes statuses, redirects, slow responses, sizes, and echoes request details. scripts/demo-requests.sh drives the CLI pane and inspector with varied methods, user agents, and payloads.

Regenerate the README screenshot (docs/screenshots/cli.gif) with:

source scripts/demo/setup.sh               # start servers + schedule requests
vhs scripts/demo/demo.tape                # records docs/screenshots/cli.gif

Limitations

  • HTTP only. There is no WebSocket upgrade support; the proxy forwards plain HTTP requests and strips Connection/Upgrade.
  • No built-in TLS. Terminate TLS at a reverse proxy in front of inlet.
  • The inspector listens on loopback only and keeps the last 200 requests in memory.
  • HTTP request/response bodies are streamed in chunks over the control WebSocket, so large payloads no longer kill the connection. Individual control messages remain capped at 512 KiB.
  • Set INLET_PUBLIC_BASE for the exact public URL. Otherwise the server derives one from the control request and honors X-Forwarded-Proto / X-Forwarded-Host. When a forwarded proto is present, the proxy's host is treated as the public host without adding inlet's internal listen port.
  • The control WebSocket rejects cross-origin browser requests. Open signup is rate limited per IP (5 attempts/hour, 3 unrevoked open tokens/IP); control connections are not. Tokens are long-lived bearer credentials; terminate TLS at a reverse proxy and keep the backend listener private where possible.

License

MIT

About

Expose local HTTP apps through stable public URLs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages