hostebin

Upload a bundle of files, get a URL you can hand to a person.

CI Release Go reference MIT license

--- Your agent just produced a 400-line HTML report. Pasting it into chat is useless. `hostebin up report.html` returns a link that opens in a browser. ```console $ hostebin up report.html https://paste.example.com/b/9f2a1c7d40b8e35f/ ``` One static Go binary is both the server and the client. Storage is a directory. Reads are public, every mutation needs a bearer token, and the 128-bit bundle ID in the URL is the read capability. - **Bundles, not files.** Upload a page with its images and CSS; relative links work. - **Markdown renders** to a styled page; `?raw=1` gives you the source. - **Agent-shaped output.** `up` prints exactly one URL on stdout, diagnostics on stderr, `--json` for everything else. Ready-made [agent skills](skills/) included. - **Serve it anywhere.** Localhost, a VPS with Let's Encrypt, or a tailnet with embedded Tailscale — no `tailscaled`, no `NET_ADMIN`. ## Install
Go ```sh go install github.com/readeem/hostebin/cmd/hostebin@latest ```
Debian/Ubuntu ```sh curl -fsSLO https://github.com/readeem/hostebin/releases/latest/download/hostebin_linux_amd64.deb sudo dpkg -i hostebin_linux_amd64.deb ```
Fedora/RHEL,
Alpine
```sh sudo rpm -i https://github.com/readeem/hostebin/releases/latest/download/hostebin_linux_amd64.rpm ``` `.apk` packages are attached to every release too.
Binary ```sh curl -fsSL https://github.com/readeem/hostebin/releases/latest/download/hostebin_linux_amd64.tar.gz \ | tar xz hostebin && sudo install hostebin /usr/local/bin/ ```
Docker ```sh docker run --rm -p 127.0.0.1:8080:8080 -v hostebin-data:/data ghcr.io/readeem/hostebin ```
> `go install`, the release binaries, the Linux packages, and the container image > work from the first tagged release. Archives, `checksums.txt`, and Linux packages are published for Linux, macOS, Windows, and FreeBSD on amd64, arm64, 386, and armv7. File names carry no version, so `releases/latest/download/...` always resolves to the newest release. Verify a download with `sha256sum -c checksums.txt --ignore-missing`. ## Quick start **1. Start a local server:** ```sh hostebin serve ``` On first run it generates a token, stores it in the data directory with mode `0600`, and logs the token-file location. Read the token from that file; it is never logged. Bootstrap settings and the token file only initialize an empty user store. After rotation, keep the replacement securely: restarts preserve the stored token and ignore bootstrap credentials. The plain HTTP listener defaults to loopback; use HTTPS or a private tailnet for remote access. **2. Point the client at it:** ```sh export HOSTEBIN_SERVER=http://localhost:8080 export HOSTEBIN_TOKEN="$(cat "${XDG_DATA_HOME:-$HOME/.local/share}/hostebin/token")" ``` **3. Upload:** ```sh hostebin up report.html # one page hostebin up report.html img/chart.png # page plus assets hostebin up ./site/ # a whole directory echo '# Status' | hostebin up -n status.md - # from stdin ``` To persist the settings instead of exporting them every session, put them in `~/.config/hostebin/config.json` (see [Configuration](#configuration)). ## Using it from an agent The [`skills/`](skills/) directory contains two drop-in skills: `hostebin` teaches an agent when and how to publish, and `beautiful-html` teaches it to write a page worth reading first. For Claude Code: ```sh mkdir -p ~/.claude/skills && cp -r skills/hostebin skills/beautiful-html ~/.claude/skills/ ``` For any other agent, both `SKILL.md` files are plain Markdown — append them to `AGENTS.md` or your system prompt. The contract that makes this safe to script: | Behaviour | Detail | | --- | --- | | stdout | Exactly one line: the bundle URL | | stderr | Every diagnostic, warning, and error | | `--json` | Full response: `id`, `url`, `entry_url`, `files[]`, `expires_at` | | exit codes | `0` success, `1` usage/config error, `2` network or server error | | config | `HOSTEBIN_SERVER` and `HOSTEBIN_TOKEN` are all an agent needs | ```sh url=$(hostebin up --ttl 7d --title 'Weekly report' report.html) || exit $? echo "Report: $url" ``` Iterate on an artifact without invalidating the link you already shared: ```sh hostebin up --id 9f2a1c7d40b8e35f report.html ``` ## Commands ```text hostebin up [flags] ... upload a bundle, print its URL hostebin ls [flags] list live bundles hostebin rm [flags] delete a bundle hostebin user ls|add|rm|disable|enable manage users hostebin token new|rm rotate or revoke a token hostebin whoami [--json] show the current identity hostebin serve [flags] run the server hostebin theme [omarchy|FILE] print a page theme hostebin theme set [omarchy|FILE] | rm set or remove your page theme hostebin version version, commit, build date ``` `up` flags: | Flag | Meaning | | --- | --- | | `--title TEXT` | Bundle title | | `--ttl DURATION` | `30m`, `12h`, `7d`, `2w`, or `never` | | `--entry PATH` | File served at the bundle root | | `--id ID` | Replace an existing bundle in place | | `-n, --name PATH` | Name for data read from stdin (required with `-`) | | `--json` | Print the JSON response instead of the URL | | `--open` | Open the URL in the system browser | | `--quiet` | Suppress optional diagnostics | | `--server`, `--token`, `--config` | Override the resolved configuration | Directory uploads strip the directory argument itself and keep the paths below it; symlinks and non-regular files are refused. `--entry` picks the root page; otherwise a single file wins, then `index.html`, then the first HTML file, then the first Markdown file, then a generated listing. ## Users and access tokens User-management JSON bodies are limited to 8 KiB and token labels to 256 bytes. The token created on first start belongs to the `admin` user. Admins can create and delete users, disable or enable accounts, and manage anyone's token. Regular users can rotate and revoke only their own token. `token new` replaces the current one, which stops working immediately. The new plaintext is printed exactly once: ```sh hostebin user add bob --ttl 90d # stdout is Bob's token hostebin token new --user bob # rotates Bob's token; stdout is the replacement hostebin user ls # includes token metadata hostebin user disable bob hostebin token rm --user bob # revokes Bob's token ``` Bundle listing, replacement, and deletion are isolated by owner. A non-owner receives the same `404` as for an unknown bundle. Admins are the exception: they can replace or delete any bundle, so abusive content can be removed without deleting its owner. Admin listing stays scoped by default — `hostebin ls` shows an admin their own bundles, and `hostebin ls --all` adds every other owner's. Regular users get their own view even if they pass `--all`. Reads stay public and unauthenticated. The 128-bit bundle ID is still the read capability. Isolation is an API-surface property: it stops Bob listing, editing, or deleting Alice's bundles. It does not stop him reading one whose URL he has, and it does not change the existing shared-origin caveat. ## Configuration Precedence: **flags → `HOSTEBIN_*` environment → JSON config → defaults.** Every long flag has an environment variable formed by uppercasing it and replacing `-` with `_` (`--max-upload` → `HOSTEBIN_MAX_UPLOAD`). A `0600` config file is created on first use at `${XDG_CONFIG_HOME:-~/.config}/hostebin/config.json` (Linux/BSD), `~/Library/Application Support/hostebin/config.json` (macOS), or `%AppData%\hostebin` (Windows). Override with `--config` or `HOSTEBIN_CONFIG`. JSON keys are the long flag names: ```json { "server": "https://hostebin.example.com", "token": "replace-me", "host": "127.0.0.1", "port": 8080, "data": "/home/me/.local/share/hostebin", "base-url": "", "tls-addr": ":8443", "tls-cert": "", "tls-key": "", "acme-domain": "", "acme-email": "", "tailscale": false, "funnel": false, "ts-hostname": "hostebin", "ts-auth-key": "", "max-upload": "32MiB", "max-files": 64, "default-ttl": "never", "csp": "", "bundle-host": "", "theme": "" } ``` Persistent data (bundles, `users.json`, plus the legacy bootstrap `token` file) lives in `${XDG_DATA_HOME:-~/.local/share}/hostebin` on Linux/BSD and `/hostebin/data` elsewhere. ## Running a server ```sh # plain HTTP on loopback, for local use or a local HTTPS proxy hostebin serve --port 8080 --data /var/lib/hostebin # your own certificate, with or without plain HTTP alongside hostebin serve --port 0 --tls-addr :8443 --tls-cert cert.pem --tls-key key.pem # automatic public HTTPS; owns :80 (ACME challenges + redirect) and :443 hostebin serve --port 0 --acme-domain files.example.com --acme-email ops@example.com # embedded Tailscale — no tailscaled, no /dev/net/tun, no NET_ADMIN HOSTEBIN_TS_AUTH_KEY=tskey-auth-... hostebin serve --port 0 --tailscale HOSTEBIN_TS_AUTH_KEY=tskey-auth-... hostebin serve --port 0 --funnel # public ``` Tailscale state persists under `$HOSTEBIN_DATA/tsnet`; the server tries tailnet HTTPS first and logs an HTTP fallback if HTTPS is not enabled for the tailnet. `--base-url` overrides the URL returned by every listener. Without it, Tailscale and ACME use their known DNS names and other listeners derive links from the request. Behind a reverse proxy, set a canonical `--base-url`, validate `Host`, and overwrite `X-Forwarded-Proto` at the proxy. Public access must use HTTPS for both content and the API. Keep the plain HTTP backend reachable only by the proxy, or disable it with `--port 0` when using a TLS listener. The plain Docker profile publishes its HTTP port on loopback; private tailnet HTTP is encrypted by Tailscale transport. Existing configs with `"host": ""` still bind all interfaces; set `"host"` to `"127.0.0.1"` for a proxy on the same machine. Containers explicitly bind all container interfaces so the proxy or loopback-published port can reach them. Useful server settings: `--max-upload` (default `32MiB`), `--max-files` (default `64`), `--default-ttl` (default `never`), `--csp` (`off` disables it), `--theme` (see [Themes](#themes)). Expired bundles 404 immediately; a sweep at startup and every ten minutes reclaims their files. Headers must arrive within ten seconds, complete requests within 60 seconds, and responses within five minutes; idle keep-alive connections close after 60 seconds. Uploads must fit the read deadline as well as `--max-upload`. ### Path-based or host-based routing A bundle can be reached in one of two shapes, chosen once per server: | | Path-based (default) | Host-based (`--bundle-host`) | | --- | --- | --- | | URL | `https://paste.example.com/b//` | `https://.paste.example.com/` | | Configuration | nothing — this is the default | `--bundle-host '*.paste.example.com'` | | DNS | one A/AAAA record | wildcard record for `*.paste.example.com` | | TLS | anything, including built-in ACME | a **wildcard certificate** you supply | | Browser origin | all bundles share one, with the API | one per bundle, none shared with the API | | Where the id is exposed | URL path only | also DNS queries and TLS SNI | **Use path-based routing** — the default — for localhost, a tailnet, a single-user VPS, or anywhere you cannot get a wildcard certificate. It is the only mode the built-in listeners can serve on their own: `--acme-domain` refuses to start alongside `--bundle-host`, and Tailscale issues certificates per node, not per wildcard, so `--tailscale` and `--funnel` are effectively path-based too. **Use host-based routing** when bundles come from more than one person, or from agents whose output you do not fully control, and you want the browser to enforce the separation. Under path-based routing every bundle shares an origin, so a page in one bundle can read another bundle's `localStorage` or `fetch` its files if it knows that bundle's id, and it shares an origin with `/api/v1/*`. A distinct origin per bundle removes that class of problem entirely. #### Switching to host-based routing ```sh hostebin serve --port 0 --tls-addr :8443 \ --tls-cert wildcard.pem --tls-key wildcard-key.pem \ --bundle-host '*.paste.example.com' ``` 1. Point a wildcard DNS record at the server. 2. Obtain a certificate for `*.paste.example.com`. Terminate TLS at a reverse proxy holding it (forwarding the original `Host` and `X-Forwarded-Proto`), or pass it to `--tls-cert`/`--tls-key`. 3. Start with `--bundle-host '*.paste.example.com'`. The value must begin with `*.` and the wildcard matches a single label; a bare hostname is rejected at startup. Nothing changes on the client side: upload responses return the subdomain form, and existing `/b//…` links 301 to it, so links you shared before the switch keep working. `--base-url` still controls the scheme and port used to build bundle URLs. Built-in ACME cannot issue the certificate: `autocert` implements only `http-01` and `tls-alpn-01`, while Let's Encrypt issues wildcards only over `dns-01`. Combining `--bundle-host` with `--acme-domain` is a startup error rather than a silent fallback — per-host issuance would publish every bundle id to the public Certificate Transparency logs, defeating the point of an unguessable link. The trade-off for the origin separation is that the id moves from the URL path into the hostname, so it becomes visible to DNS resolvers and to anyone on the network path via TLS SNI. Bundle responses send `Referrer-Policy: no-referrer` under both modes, so the id is not handed to third-party origins the page loads from. Responses also send `Cache-Control: no-store` and `X-Robots-Tag: noindex, nofollow, nosnippet`. Configure any proxy or CDN to honor these headers and purge previously cached copies when adopting this policy. Deletion cannot revoke copies a recipient has already saved. The default Markdown renderer permits only its own hashed script and the pinned, integrity-checked Highlight.js asset. Scripts embedded in Markdown are blocked; a custom `--csp` overrides this policy. Uploaded HTML retains the general content policy described above. #### Dokploy + Cloudflare Deploy `ghcr.io/readeem/hostebin` as a Dokploy application or Compose service, with a volume on `/data`. **Path-based** needs nothing special: under *Domains*, add `hostebin.example.com` on container port `8080` with HTTPS and the Let's Encrypt certificate provider. **Host-based** takes three more steps, because Dokploy has no wildcard-domain field and Let's Encrypt only issues wildcards over `dns-01`: 1. **Cert resolver.** In `/dashboard/traefik`, add a DNS-challenge resolver to the global static config, and set `CF_DNS_API_TOKEN` in Traefik's environment to a Cloudflare token with *Zone → Zone → Read* and *Zone → DNS → Edit*: ```yaml certificatesResolvers: cloudflare: acme: email: ops@example.com storage: /etc/dokploy/traefik/dynamic/acme.json dnsChallenge: provider: cloudflare ``` 2. **Router rule.** Add the domain first — Dokploy generates the routing from it, and for an application the Traefik config editor only appears at the bottom of *Advanced* once one exists. Then widen the rule on both routers and request the wildcard on the `websecure` one: ```yaml rule: Host(`hostebin.example.com`) || HostRegexp(`^[a-z0-9-]+\.hostebin\.example\.com$`) tls: certResolver: cloudflare domains: - main: hostebin.example.com sans: - "*.hostebin.example.com" ``` For a Compose service the routing lives in the generated Traefik labels instead, so edit those on the service — same rule, same resolver: ```yaml labels: - "traefik.http.routers.hostebin-websecure.rule=Host(`hostebin.example.com`) || HostRegexp(`^[a-z0-9-]+\\.hostebin\\.example\\.com$`)" - "traefik.http.routers.hostebin-websecure.tls.certresolver=cloudflare" - "traefik.http.routers.hostebin-websecure.tls.domains[0].main=hostebin.example.com" - "traefik.http.routers.hostebin-websecure.tls.domains[0].sans=*.hostebin.example.com" ``` Either way Traefik forwards the original `Host` and sets `X-Forwarded-Proto`, which is what `--bundle-host` needs to build correct URLs. 3. **DNS.** Add an A record for `*.hostebin.example.com` pointing at the server as **DNS-only (grey cloud)**. Cloudflare's Universal SSL covers only the apex and first-level subdomains, so a proxied `*.hostebin.example.com` fails TLS unless you pay for Advanced Certificate Manager. Your `hostebin.example.com` (or `*.example.com`) records can stay proxied. Finally, set `HOSTEBIN_BUNDLE_HOST=*.hostebin.example.com` in the service's environment. All deployments need a redeploy/reload for domain and env changes to take effect. The `.deb`, `.rpm`, and Arch packages install a hardened systemd unit: ```sh sudo systemctl enable --now hostebin sudo systemctl cat hostebin # data lives in /var/lib/hostebin ``` ### Containers ```sh docker run --rm -p 127.0.0.1:8080:8080 -v hostebin-data:/data ghcr.io/readeem/hostebin HOSTEBIN_TOKEN=choose-a-secret docker compose --profile plain up HOSTEBIN_TS_AUTH_KEY=tskey-auth-... HOSTEBIN_TOKEN=choose-a-secret \ docker compose --profile tailscale up ``` Images are `CGO_ENABLED=0` on distroless nonroot, published for `linux/amd64` and `linux/arm64`. The Tailscale Compose service needs no elevated capabilities. ## Themes Rendered Markdown, file listings, and every page that links `.hostebin/theme.css` take their colours from a theme. The [`beautiful-html`](skills/beautiful-html/) templates all link it. Each page gets the server's theme, then its owner's theme on top. ### Your theme Every user can set their own, and it applies to every bundle they own: ```sh hostebin theme set # the active Omarchy theme on this machine hostebin theme set brand.css # a theme file hostebin theme rm # back to the server's theme ``` Admins can add `--user NAME` to change someone else's. Pages read the theme whenever they load, so a new theme restyles bundles that are already published. A page opened from disk, or published anywhere else, keeps the default colours it carries. A theme file is a `:root` block, and it only needs the tokens it changes: ```css :root { --accent: #3b82f6; } ``` `hostebin theme default` prints the full palette to start from. Surfaces, borders, and muted text are mixed from `--canvas` and `--ink`, so they follow on their own. A light theme sets `color-scheme: light` and dark enough status (`--ok`, `--warn`, `--bad`) and code (`--t-*`) colours. A theme file is used as written; only Omarchy themes are checked for contrast. Themes are limited to 64 KiB and may not contain `<`. ### Omarchy `hostebin theme` and `hostebin theme set` read the active theme from `~/.local/state/omarchy/current/theme/colors.toml`. They map its background, foreground, accent, and terminal colours onto pages, with two safeguards: - If the theme's red, yellow, or green isn't recognisably that colour, or its red is the accent, the page keeps the built-in status colour. Monochrome themes would otherwise mark failures in the same colour as links. - Text colours are lightened or darkened until they reach WCAG AA on every background they appear on. To keep your pages in step with your desktop, re-send the theme on every switch with an Omarchy hook, `~/.config/omarchy/hooks/theme-set.d/50-hostebin` (make it executable): ```bash #!/bin/bash exec hostebin theme set omarchy ``` ### The server's theme `hostebin serve --theme` (`HOSTEBIN_THEME`, `"theme"` in the config file) sets the theme under everyone's, and is all that users without a theme of their own see: | `--theme` | Colours | | --- | --- | | unset | The active Omarchy theme when the server runs as an Omarchy user, otherwise the default | | `default` | The built-in near-black and crimson | | `omarchy` | The active Omarchy theme; startup fails without one | | `path/to/theme.css` | That file, layered over the default | The server rereads it on every request, so editing the file or switching the Omarchy theme needs no restart. ### Setup prompt Paste this to your agent to set themes up for you: ```text Set up my hostebin page theme. Read the "Themes" section of https://github.com/readeem/hostebin/blob/main/README.md first. 1. Check that `hostebin whoami` works, so the client reaches my server. 2. Check whether this machine runs Omarchy: ~/.local/state/omarchy/current/theme/colors.toml. 3. On Omarchy: run `hostebin theme set`, then add the theme-set hook from the README so every theme switch updates my pages. Switch nothing; show me the output of `hostebin theme` and the hook instead. 4. Not on Omarchy: ask me for an accent colour and whether I want light or dark, write a theme file starting from `hostebin theme default`, and run `hostebin theme set FILE`. 5. Publish a small Markdown file with `hostebin up` and give me its URL so I can see the result. ``` ## HTTP API All `/api/v1` routes require `Authorization: Bearer TOKEN`. Bundle content and `/healthz` are public. ```sh curl -sf -H "Authorization: Bearer $HOSTEBIN_TOKEN" \ -H 'X-Hostebin-Filename: report.html' \ --data-binary @report.html \ "$HOSTEBIN_SERVER/api/v1/bundles" curl -sf -H "Authorization: Bearer $HOSTEBIN_TOKEN" \ -F 'file=@report.html;filename=report.html' \ -F 'file=@img/chart.png;filename=img/chart.png' \ "$HOSTEBIN_SERVER/api/v1/bundles" ``` | Route | Purpose | | --- | --- | | `POST /api/v1/bundles` | Create from multipart `file` parts or a raw body with `X-Hostebin-Filename` | | `PUT /api/v1/bundles/{id}?mode=replace` | Atomically replace a bundle | | `PUT /api/v1/bundles/{id}?mode=merge` | Atomically merge named files into a bundle | | `GET /api/v1/bundles` | List live bundles | | `GET /api/v1/bundles?all=1` | Admin global view with owners | | `DELETE /api/v1/bundles/{id}` | Delete a bundle | | `GET /api/v1/whoami` | Show the authenticated user and token label | | `GET, POST /api/v1/users` | List or create users (admin) | | `PATCH, DELETE /api/v1/users/{id}` | Disable/enable or delete a user (admin) | | `PUT /api/v1/users/{id}/token` | Atomically replace the user's token (admin or self) | | `DELETE /api/v1/users/{id}/token` | Revoke the user's token (admin or self) | | `GET /b/{id}/...` | Read public content; `?raw=1` disables Markdown rendering | | `PUT /api/v1/users/{id}/theme` | Set the user's page theme from a CSS body (admin or self) | | `DELETE /api/v1/users/{id}/theme` | Remove the user's page theme (admin or self) | | `GET /.hostebin/theme.css` | The server's theme | | `GET /b/{id}/.../.hostebin/theme.css` | The theme for this bundle: the server's, then its owner's | `PUT /api/v1/users/{id}/token` returns the replacement plaintext once. The previous token is invalid for the next request. Its `{label, ttl}` body is optional; sending no body accepts the defaults. Raw uploads also accept `X-Hostebin-Title`, `X-Hostebin-Entry`, and `X-Hostebin-TTL`; multipart uploads use `title`, `entry`, and `ttl` fields. ## Content security model Hosted files are untrusted. The server never uses cookie authentication, sends `X-Content-Type-Options: nosniff`, and serves unknown extensions as downloadable `application/octet-stream`. Uploaded files carry this default CSP; rendered Markdown additionally restricts scripts to its two approved hashes: ```text default-src 'self' data: blob: https: 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https:; form-action 'none'; frame-ancestors 'none' ``` Inline scripts/styles and HTTPS CDN assets are intentionally allowed because generated reports need them, and `connect-src` permits HTTPS so a report can render live data. Plain HTTP is deliberately excluded: it keeps a published page from probing the reader's own network (`http://192.168.x.x`, `http://localhost:11434`) from their browser. This is not a sandbox. `script-src` falls back to `https:`, so a bundle can already load and execute code from any HTTPS origin, and `img-src` can carry data outward in a URL — the CSP limits accidents, not a malicious uploader. The threat model is that whoever holds the upload token is trusted, and the reader and their network are not. By default bundles share an origin, so content can reach another bundle whose unguessable ID it knows. `--bundle-host` gives each bundle its own origin and removes that class entirely; see [Path-based or host-based routing](#path-based-or-host-based-routing). **While bundles share an origin with the API, the API must never accept ambient credentials.** It is bearer-token only for exactly this reason: a cookie session would let any published page call `/api/v1/*` with the reader's credentials. Writes stage in hidden temporary directories before an atomic rename; reads go through `os.OpenRoot`, so `..` and symlinks cannot escape the storage root. **Treat every bundle URL as a secret-bearing link, and never publish credentials or personal data.** ## Development [`just`](https://github.com/casey/just) drives everything: ```sh just # list recipes just build # ./hostebin, version stamped from the current git tag just check # gofmt, go vet, go test — the CI gate just test-all # default, notsnet, and -race test runs just serve # run the server from source ``` Version lives in exactly one place, [`internal/version`](internal/version/version.go), and every build path (justfile, Dockerfile, GoReleaser) overrides it with the same `-ldflags` target. A build without ldflags falls back to the module version recorded by the Go toolchain. Smaller binary that rejects Tailscale flags: ```sh just build-slim # go build -tags notsnet ``` ## Releasing ```sh just tag 0.1.0 # tags v0.1.0 and pushes it ``` The tag triggers [`release.yml`](.github/workflows/release.yml): GoReleaser builds every target, attaches archives, checksums, and Linux packages to the GitHub release, updates the Homebrew/Scoop/AUR manifests, and a second job publishes the container image to GHCR. ## License [MIT](LICENSE) © Readeem