From d404c2a45a51215f2db10cd112308ba20e6ce022 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Wed, 26 Aug 2026 21:35:17 +0000 Subject: [PATCH 1/3] feat: add self-hosting runtime checks Amp-Thread-ID: https://ampcode.com/threads/T-01a03fee-fa37-71e5-abc0-0671407acb56 --- .github/workflows/elixir.yml | 4 +- config/runtime.exs | 21 ++++++++ lib/textbin/accounts/user_notifier.ex | 5 +- .../controllers/health_controller.ex | 21 ++++++++ lib/textbin_web/router.ex | 5 ++ test/textbin/accounts/user_notifier_test.exs | 33 ++++++++++++ test/textbin/config_test.exs | 51 +++++++++++++++++-- .../controllers/health_controller_test.exs | 15 ++++++ 8 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 lib/textbin_web/controllers/health_controller.ex create mode 100644 test/textbin/accounts/user_notifier_test.exs create mode 100644 test/textbin_web/controllers/health_controller_test.exs diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index ced8876..e1c6b09 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -222,6 +222,7 @@ jobs: test "$(id -u)" = "1000" test "$(id -g)" = "1000" test -x /app/bin/textbin + test -x /app/bin/grant-platform-admin test -x /app/bin/migrate touch /var/lib/textbin/pastes/.write-test touch /var/lib/textbin/uploads/.write-test @@ -285,4 +286,5 @@ jobs: done test "$ready" = "true" - curl --fail --silent --show-error --output /dev/null http://127.0.0.1:4100/ + curl --fail --silent --show-error --output /dev/null http://127.0.0.1:4100/healthz + curl --fail --silent --show-error --output /dev/null http://127.0.0.1:4100/readyz diff --git a/config/runtime.exs b/config/runtime.exs index 95a4112..68c7ac0 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -59,6 +59,27 @@ case storage_backend do raise "unsupported TEXTBIN_STORAGE_BACKEND: #{inspect(unsupported)}" end +case System.get_env("TEXTBIN_MAILER_BACKEND") do + nil -> + if mail_from_address = System.get_env("MAIL_FROM_ADDRESS") do + config :textbin, :mail_from, + name: System.get_env("MAIL_FROM_NAME") || "Textbin", + address: mail_from_address + end + + "postmark" -> + config :textbin, Textbin.Mailer, + adapter: Swoosh.Adapters.Postmark, + api_key: System.fetch_env!("POSTMARK_API_KEY") + + config :textbin, :mail_from, + name: System.get_env("MAIL_FROM_NAME") || "Textbin", + address: System.fetch_env!("MAIL_FROM_ADDRESS") + + unsupported -> + raise "unsupported TEXTBIN_MAILER_BACKEND: #{inspect(unsupported)}" +end + if config_env() == :prod do parse_port = fn name, default -> case Integer.parse(System.get_env(name) || default) do diff --git a/lib/textbin/accounts/user_notifier.ex b/lib/textbin/accounts/user_notifier.ex index b5802d2..c4be74a 100644 --- a/lib/textbin/accounts/user_notifier.ex +++ b/lib/textbin/accounts/user_notifier.ex @@ -10,10 +10,13 @@ defmodule Textbin.Accounts.UserNotifier do # Delivers the email using the application mailer. defp deliver(recipient, subject, body) do + sender = + Application.get_env(:textbin, :mail_from, name: "Textbin", address: "contact@example.com") + email = new() |> to(recipient) - |> from({"Textbin", "contact@example.com"}) + |> from({sender[:name], sender[:address]}) |> subject(subject) |> text_body(body) diff --git a/lib/textbin_web/controllers/health_controller.ex b/lib/textbin_web/controllers/health_controller.ex new file mode 100644 index 0000000..30c5766 --- /dev/null +++ b/lib/textbin_web/controllers/health_controller.ex @@ -0,0 +1,21 @@ +defmodule TextbinWeb.HealthController do + use TextbinWeb, :controller + + @database_timeout 2_000 + + @doc "Reports whether the HTTP process is running without checking dependencies." + def live(conn, _params) do + send_resp(conn, :ok, "ok\n") + end + + @doc "Reports whether the application can serve requests that require PostgreSQL." + def ready(conn, _params) do + case Textbin.Repo.query("SELECT 1", [], + timeout: @database_timeout, + pool_timeout: @database_timeout + ) do + {:ok, _result} -> send_resp(conn, :ok, "ready\n") + {:error, _reason} -> send_resp(conn, :service_unavailable, "unavailable\n") + end + end +end diff --git a/lib/textbin_web/router.ex b/lib/textbin_web/router.ex index 09fd58d..8353049 100644 --- a/lib/textbin_web/router.ex +++ b/lib/textbin_web/router.ex @@ -22,6 +22,11 @@ defmodule TextbinWeb.Router do plug :require_api_token end + scope "/", TextbinWeb do + get "/healthz", HealthController, :live + get "/readyz", HealthController, :ready + end + scope "/", TextbinWeb do pipe_through :browser diff --git a/test/textbin/accounts/user_notifier_test.exs b/test/textbin/accounts/user_notifier_test.exs new file mode 100644 index 0000000..1a88c8e --- /dev/null +++ b/test/textbin/accounts/user_notifier_test.exs @@ -0,0 +1,33 @@ +defmodule Textbin.Accounts.UserNotifierTest do + use ExUnit.Case, async: false + + alias Textbin.Accounts.{User, UserNotifier} + + import Swoosh.TestAssertions + + setup do + previous = Application.get_env(:textbin, :mail_from) + + on_exit(fn -> + if previous do + Application.put_env(:textbin, :mail_from, previous) + else + Application.delete_env(:textbin, :mail_from) + end + end) + end + + test "uses the configured production sender" do + Application.put_env(:textbin, :mail_from, + name: "Example Textbin", + address: "textbin@example.com" + ) + + user = %User{email: "recipient@example.com", confirmed_at: DateTime.utc_now()} + + assert {:ok, _email} = + UserNotifier.deliver_login_instructions(user, "https://example.com/login") + + assert_email_sent(from: {"Example Textbin", "textbin@example.com"}) + end +end diff --git a/test/textbin/config_test.exs b/test/textbin/config_test.exs index c45fb05..8f8eac4 100644 --- a/test/textbin/config_test.exs +++ b/test/textbin/config_test.exs @@ -6,7 +6,10 @@ defmodule Textbin.ConfigTest do "PHX_HOST" => "textbin.example.com", "SECRET_KEY_BASE" => String.duplicate("a", 64) } - @optional_production_env ~w(HTTPS_PORT POOL_SIZE PORT TLS_CERT_PATH TLS_KEY_PATH) + @optional_production_env ~w( + HTTPS_PORT MAIL_FROM_ADDRESS MAIL_FROM_NAME POOL_SIZE PORT POSTMARK_API_KEY + TEXTBIN_MAILER_BACKEND TLS_CERT_PATH TLS_KEY_PATH + ) setup do names = Map.keys(@production_env) ++ @optional_production_env @@ -119,12 +122,52 @@ defmodule Textbin.ConfigTest do end end + test "production configures Postmark and its sender at runtime" do + System.put_env(%{ + "MAIL_FROM_ADDRESS" => "textbin@example.com", + "MAIL_FROM_NAME" => "Example Textbin", + "POSTMARK_API_KEY" => "server-token", + "TEXTBIN_MAILER_BACKEND" => "postmark" + }) + + config = production_config() + + assert get_in(config, [:textbin, Textbin.Mailer]) == [ + adapter: Swoosh.Adapters.Postmark, + api_key: "server-token" + ] + + assert get_in(config, [:textbin, :mail_from]) == [ + name: "Example Textbin", + address: "textbin@example.com" + ] + end + + test "production rejects unsupported mail backends" do + System.put_env("TEXTBIN_MAILER_BACKEND", "smtp") + + assert_raise RuntimeError, ~r/unsupported TEXTBIN_MAILER_BACKEND: "smtp"/, fn -> + production_config() + end + end + + test "production requires a sender address for Postmark" do + System.put_env(%{ + "POSTMARK_API_KEY" => "server-token", + "TEXTBIN_MAILER_BACKEND" => "postmark" + }) + + assert_raise System.EnvError, ~r/MAIL_FROM_ADDRESS/, fn -> + production_config() + end + end + defp production_endpoint_config do - "config/runtime.exs" - |> Config.Reader.read!(env: :prod) - |> get_in([:textbin, TextbinWeb.Endpoint]) + get_in(production_config(), [:textbin, TextbinWeb.Endpoint]) end + defp production_config, do: Config.Reader.read!("config/runtime.exs", env: :prod) + defp restore_env(name, nil), do: System.delete_env(name) defp restore_env(name, value), do: System.put_env(name, value) end diff --git a/test/textbin_web/controllers/health_controller_test.exs b/test/textbin_web/controllers/health_controller_test.exs new file mode 100644 index 0000000..35613b6 --- /dev/null +++ b/test/textbin_web/controllers/health_controller_test.exs @@ -0,0 +1,15 @@ +defmodule TextbinWeb.HealthControllerTest do + use TextbinWeb.ConnCase, async: true + + test "GET /healthz reports process liveness", %{conn: conn} do + conn = get(conn, ~p"/healthz") + + assert response(conn, 200) == "ok\n" + end + + test "GET /readyz reports PostgreSQL readiness", %{conn: conn} do + conn = get(conn, ~p"/readyz") + + assert response(conn, 200) == "ready\n" + end +end From 7bed5f30ff77e1409cc0a14cac9fe51f495744af Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Wed, 26 Aug 2026 21:35:23 +0000 Subject: [PATCH 2/3] docs: complete self-hosting guide Amp-Thread-ID: https://ampcode.com/threads/T-01a03fee-fa37-71e5-abc0-0671407acb56 --- docs/releasing.md | 4 +- docs/self-hosting.md | 629 +++++++++++++++++++++++++++--------- rfd/0002/IMPLEMENTATION.org | 10 +- 3 files changed, 478 insertions(+), 165 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index 78df49d..41b4f25 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -48,7 +48,7 @@ gate** on `main`. Review and update pinned action commit SHAs deliberately when upgrading release dependencies. The version comments beside each SHA are informational; the SHA is the security boundary. -After the first container publication, make the `chaba2/textbin` package public +After the first container publication, make the `chaba-dev/textbin` package public in GitHub's package settings as described in the self-hosting guide. ## Automated flow @@ -111,7 +111,7 @@ main changes -> release/next PR -> vMAJOR.MINOR.PATCH tag -> GitHub Release with CLI artifacts, notes, and tag comparison - -> ghcr.io/chaba2/textbin (linux/amd64 and linux/arm64) + -> ghcr.io/chaba-dev/textbin (linux/amd64 and linux/arm64) ``` ## Published CLI platforms diff --git a/docs/self-hosting.md b/docs/self-hosting.md index b1dafda..fd9f3fd 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -1,208 +1,521 @@ # Self-hosting Textbin -Textbin ships as a portable OCI image. The image does not assume Docker -Compose, a particular orchestrator, an ingress controller, or how secrets and -persistent storage are provided. +Textbin publishes a portable OCI image and supports the runtime contract in this +guide. The project does **not** maintain production Docker Compose, Kubernetes, +Helm, Terraform, cloud-provider, TLS, monitoring, or backup-scheduling +configurations. The `docker` commands below are illustrative translations of the +contract, not a production-ready stack. Operators own availability, network +policy, TLS lifecycle, secret management, monitoring, and backup automation. -Stable and prerelease images are published from GitHub Releases as: +## Image and dependency contract -```text -ghcr.io/chaba2/textbin: -``` +Release images are published at `ghcr.io/chaba-dev/textbin`. They support +`linux/amd64` and `linux/arm64`. -Images support `linux/amd64` and `linux/arm64`. Pin an exact semantic-version -tag or, for reproducible deployments, the published manifest digest. Stable -releases also update the matching major and minor tags plus `latest`; -prereleases update only their complete version and commit-SHA tags. +- Use an exact release tag such as `0.1.0`, not `latest`, a major tag, or a minor + tag. +- For reproducible deployments, resolve the multi-architecture manifest and pin + `ghcr.io/chaba-dev/textbin@sha256:`. Record that digest with the + backup and deployment metadata. +- Stable releases update their exact, major, minor, commit-SHA, and `latest` + tags. Prereleases update only their exact and commit-SHA tags. +- The process runs as `textbin`, numeric UID/GID `1000`, and starts with + `/app/bin/textbin start`. -GitHub creates a new GHCR package as private. Before announcing the first public -release, the repository owner must change the `chaba2/textbin` package visibility -to public in GitHub's package settings. Until that one-time step is complete, -pulling the image requires GHCR authentication. +GitHub creates a new GHCR package as private. Before the first public release, +the repository owner must make `chaba-dev/textbin` public in the package settings. +Until then, pulls require GHCR authentication. -## Runtime contract +A production installation requires: -The server runs as the unprivileged `textbin` user with numeric UID and GID -`1000`. Its default command is: +1. PostgreSQL 17 on a private network; +2. either a durable POSIX filesystem or a supported S3-compatible object store; +3. writable upload staging space; +4. public HTTPS, terminated by a reverse proxy or by Textbin; and +5. Postmark email delivery if users need to register and confirm accounts. -```bash -/app/bin/textbin start -``` +PostgreSQL metadata and non-inline paste objects form one durability boundary. +Losing either can make paste content unreadable. -The process listens for HTTP on `PORT` (`4000` by default) and can optionally -terminate TLS on `HTTPS_PORT` (`4443` by default). Terminate it using the -runtime's normal `SIGTERM` and grace-period mechanism. +## Environment variables -The following configuration is required in production: +Pass secrets through the platform's secret facility. Do not bake them into an +image, commit an environment file, put credentials in command history, or print +the effective environment while troubleshooting. -| Variable | Purpose | -|-------------------|---------------------------------------------------| -| `DATABASE_URL` | PostgreSQL connection URL | -| `SECRET_KEY_BASE` | Phoenix signing and encryption secret | -| `PHX_HOST` | Public hostname used when generating URLs | -| `PORT` | HTTP listener port; defaults to `4000` | -| `POOL_SIZE` | Connections per PostgreSQL pool; defaults to `10` | +Required for every server and release command: -Generate `SECRET_KEY_BASE` with `mix phx.gen.secret` from a source checkout or -another cryptographically secure secret generator. Supply secrets through the -runtime's secret mechanism rather than baking them into an image layer. +| Variable | Contract | +|---|---| +| `DATABASE_URL` | PostgreSQL URL, for example `ecto://textbin:@postgres/textbin_prod` | +| `SECRET_KEY_BASE` | At least 64 bytes of random signing material | +| `PHX_HOST` | Public hostname only, for example `textbin.example.com` | -`PORT` and `HTTPS_PORT` must be valid TCP ports. `POOL_SIZE` must be a positive -integer. Textbin rejects invalid values during startup instead of booting with a -partial configuration. +Generate a secret without a source checkout: -## TLS termination +```sh +openssl rand -base64 64 +``` -By default, Textbin serves HTTP and expects a reverse proxy, ingress, or load -balancer to terminate public TLS. It can instead terminate TLS directly through -Bandit and Erlang/OTP's TLS stack: +Application and database options: + +| Variable | Default | Contract | +|---|---:|---| +| `PORT` | `4000` | HTTP listener, integer from 1 through 65535 | +| `POOL_SIZE` | `10` | PostgreSQL connections per application instance, positive integer | +| `ECTO_IPV6` | unset | Set to `true` or `1` when the database hostname resolves over IPv6 | +| `DNS_CLUSTER_QUERY` | unset | Erlang DNS clustering query; leave unset for one node or without distributed clustering | +| `TEXTBIN_INLINE_PASTE_BYTES` | `8192` | Non-negative threshold below which safe UTF-8 bodies remain in PostgreSQL | +| `TEXTBIN_UPLOAD_TMP_DIR` | `/var/lib/textbin/uploads` | Private upload spool | + +Storage options: + +| Variable | Required when | Default / contract | +|---|---|---| +| `TEXTBIN_STORAGE_BACKEND` | Optional | `local`; accepted values are `local` and `s3` | +| `TEXTBIN_STORAGE_PATH` | Local | `/var/lib/textbin/pastes` | +| `S3_ENDPOINT` | S3 | Absolute HTTP(S) origin; path-style access is used | +| `S3_BUCKET` | S3 | Existing bucket name | +| `S3_REGION` | S3 | `us-east-1` | +| `S3_ACCESS_KEY_ID` | S3 | Bucket-scoped access key | +| `S3_SECRET_ACCESS_KEY` | S3 | Bucket-scoped secret key | + +Email options for supported production registration: + +| Variable | Required when | Contract | +|---|---|---| +| `TEXTBIN_MAILER_BACKEND` | Sending email | Set to `postmark` | +| `POSTMARK_API_KEY` | Postmark | Postmark server API token | +| `MAIL_FROM_ADDRESS` | Sending email | Verified sender address | +| `MAIL_FROM_NAME` | Optional | Defaults to `Textbin` | + +Without a production mail backend, the server can run but new users cannot +receive confirmation or login links. Validate the sender domain and outbound +HTTPS access to Postmark before opening registration. + +Direct TLS options: + +| Variable | Default | Contract | +|---|---:|---| +| `TLS_CERT_PATH` | unset | Readable PEM certificate; must be set with `TLS_KEY_PATH` | +| `TLS_KEY_PATH` | unset | Readable PEM private key; must be set with `TLS_CERT_PATH` | +| `HTTPS_PORT` | `4443` | Internal HTTPS listener, integer from 1 through 65535 | + +`PHX_SERVER=true` is already set in the image. Invalid integers, unsupported +backends, incomplete TLS pairs, unreadable TLS files, and missing backend secrets +fail startup. Keep one identical environment/secret set for migration jobs, +admin jobs, and application instances. + +## PostgreSQL + +Textbin's tested production baseline is PostgreSQL 17. Use a supported PostgreSQL +17 minor release, UTF-8 database encoding, durable storage, and routine database +maintenance. The application role needs connect, schema migration, table, +sequence, and advisory-lock privileges in its own database; it does not need a +PostgreSQL superuser or access to other databases. Do not expose PostgreSQL to +the public Internet. + +Size `max_connections` for at least: ```text -TLS_CERT_PATH=/run/secrets/textbin/tls.crt -TLS_KEY_PATH=/run/secrets/textbin/tls.key -HTTPS_PORT=4443 +(maximum simultaneous Textbin instances × POOL_SIZE) + maintenance headroom ``` -`TLS_CERT_PATH` and `TLS_KEY_PATH` must be supplied together and must name -readable regular files. Mount both files read-only and make them readable by -UID/GID `1000`; never place the private key in the image. The HTTP listener -remains enabled on `PORT`, which allows a separately protected health endpoint -or internal traffic. Control access to both listeners with the runtime's network -policy. - -Direct TLS works with a layer-4 load balancer. For example, an NLB can accept -TCP port `443` and pass the encrypted connection to `HTTPS_PORT=4443`. Using an -unprivileged target port avoids granting the container permission to bind port -`443`. Every replica must receive a certificate valid for `PHX_HOST` and its -corresponding key. Verify client-address preservation for the load balancer's -target mode before relying on source addresses for logs or abuse controls. - -`HTTPS_PORT` is the internal application listener, not the public URL port. -Textbin generates public URLs as `https://PHX_HOST` on port `443`, so a direct -deployment must publish or forward public port `443` to `HTTPS_PORT`. Public -HTTPS deployments on a non-standard port are not currently supported. - -Certificate renewal is the operator's responsibility. Replace the mounted -files atomically and restart or roll the application instances so Erlang/OTP -loads the renewed certificate. When a proxy or ingress already manages ACME and -certificate rotation, leave direct TLS unset and forward HTTP to `PORT`. - -## Writable paths - -The image creates these paths and grants ownership to UID/GID `1000`: - -| Path | Default variable | Durability requirement | -|----------------------------|--------------------------|-------------------------------------------------| -| `/var/lib/textbin/pastes` | `TEXTBIN_STORAGE_PATH` | Persistent when using local storage | -| `/var/lib/textbin/uploads` | `TEXTBIN_UPLOAD_TMP_DIR` | Writable staging space; persistence is optional | - -The Dockerfile deliberately does not declare either path as a `VOLUME`. -Operators can supply bind mounts, named volumes, ephemeral disks, Kubernetes -volumes, or another implementation appropriate to their runtime. - -Staged upload files are private, bounded by the configured paste limit, and -removed after finalization. Textbin tracks active staging files and periodically -removes stale files left by interrupted requests. Persisting the staging path -allows that cleanup to operate across container restarts; using ephemeral space -is also safe because unfinished request bodies are not committed paste data. -Provision staging capacity for the maximum expected number of concurrent -uploads. The current default maximum paste size is 1 MiB. - -## Run migrations explicitly - -The image never runs database migrations as a server startup side effect. Run -the included command once per deployment, with the same `DATABASE_URL` and -runtime secrets as the server: +Migration and operator jobs open transient connections, and backup tools need +headroom. Start with the default pool and increase it only after observing queue +time and database capacity. Each replica gets its own pool. -```text -/app/bin/migrate +Before deploying Textbin, verify from the same private network and with the +application credentials. PostgreSQL tools expect a `postgresql://` URL rather +than Ecto's `ecto://` scheme: + +```sh +PGDATABASE_URL='postgresql://textbin:@postgres:5432/textbin_prod' +pg_isready -d "$PGDATABASE_URL" +psql "$PGDATABASE_URL" -v ON_ERROR_STOP=1 -c 'select current_database(), version();' +unset PGDATABASE_URL ``` -The command is safe to run repeatedly and applies every pending migration. In a -multi-instance deployment, run it as a dedicated release job before replacing -the application instances. +## Writable paths and upload capacity -## Local storage +The image creates both paths with UID/GID `1000` and deliberately declares no +Docker `VOLUME`: -Select local storage with: +| Path | Durability | +|---|---| +| `/var/lib/textbin/pastes` | Persistent and backed up for local storage | +| `/var/lib/textbin/uploads` | Writable; persistence is optional | -```text +Upload files are mode-restricted, removed after finalization, and reaped when +stale. Ephemeral upload space is safe because an unfinished body is not committed +paste data. Persistent staging lets cleanup continue across restarts. Provision +at least `maximum concurrent uploads × maximum paste size`, plus filesystem +overhead and operational headroom. The current maximum paste size is 1 MiB. + +## Migrations + +The server never migrates on startup. Run exactly one migration job from the new +image before starting or rolling application instances: + +```sh +docker run --rm --network textbin-private --env-file /run/textbin/runtime.env \ + ghcr.io/chaba-dev/textbin:0.1.0 /app/bin/migrate +``` + +The command is idempotent and uses a PostgreSQL advisory lock. Keep the job logs; +a non-zero exit means the rollout must stop. Do not start the new server version +until migrations succeed. + +## Local-storage deployment + +Local storage requires one persistent filesystem mounted at +`/var/lib/textbin/pastes`. It must support same-filesystem atomic rename plus file +and directory `fsync`. Shared multi-node access is safe only when the filesystem +provides those semantics consistently to every node. Otherwise run one Textbin +instance or use S3-compatible storage. + +An illustrative environment file (replace all placeholders and set mode `0600`): + +```dotenv +DATABASE_URL=ecto://textbin:@postgres:5432/textbin_prod +SECRET_KEY_BASE= +PHX_HOST=textbin.example.com +POOL_SIZE=10 TEXTBIN_STORAGE_BACKEND=local TEXTBIN_STORAGE_PATH=/var/lib/textbin/pastes +TEXTBIN_UPLOAD_TMP_DIR=/var/lib/textbin/uploads +TEXTBIN_MAILER_BACKEND=postmark +POSTMARK_API_KEY= +MAIL_FROM_ADDRESS=textbin@example.com ``` -The configured path must: +Illustrative single-node server command: + +```sh +docker volume create textbin-pastes +docker volume create textbin-uploads +docker run --rm --user 0:0 \ + --mount type=volume,src=textbin-pastes,dst=/var/lib/textbin/pastes \ + --mount type=volume,src=textbin-uploads,dst=/var/lib/textbin/uploads \ + --entrypoint chown ghcr.io/chaba-dev/textbin:0.1.0 \ + -R 1000:1000 /var/lib/textbin/pastes /var/lib/textbin/uploads + +docker run --name textbin --read-only --restart unless-stopped \ + --network textbin-private --env-file /run/textbin/runtime.env \ + --mount type=volume,src=textbin-pastes,dst=/var/lib/textbin/pastes \ + --mount type=volume,src=textbin-uploads,dst=/var/lib/textbin/uploads \ + --publish 127.0.0.1:4000:4000 \ + ghcr.io/chaba-dev/textbin:0.1.0 +``` -- be writable by UID/GID `1000`; -- survive application replacement and host restart; -- support atomic rename and file and directory `fsync` semantics; and -- have enough capacity for retained paste bodies and temporary files created - adjacent to objects during atomic finalization. +The volume must already be writable by UID/GID `1000`. Verify before migration: -Do not use a filesystem that ignores durability operations if acknowledged -writes must survive host failure. Network filesystems and storage drivers vary; -verify their rename and synchronization guarantees before production use. +```sh +docker run --rm --user 1000:1000 \ + --mount type=volume,src=textbin-pastes,dst=/var/lib/textbin/pastes \ + --entrypoint sh ghcr.io/chaba-dev/textbin:0.1.0 -c \ + 'set -eu; p=/var/lib/textbin/pastes/.permission-check; umask 077; : > "$p"; sync "$p"; rm "$p"' +``` -## S3-compatible storage +After startup, perform the end-to-end paste check under [Verification](#verification). +It writes a body larger than the inline threshold, proving database and mounted +blob storage connectivity together. -Select S3-compatible storage with: +## S3-compatible deployment -```text +Textbin uses path-style, AWS Signature V4 PUT, GET, and DELETE requests. The +bucket must exist. Credentials need only object read, write, and delete access +within that bucket; deny bucket administration and access to other buckets. Keep +the endpoint private when possible and never expose its administration UI or API +publicly. SeaweedFS and Garage are tested development targets; test any provider's +path-style and signing compatibility before production use. + +Illustrative S3 additions to the common environment: + +```dotenv TEXTBIN_STORAGE_BACKEND=s3 -S3_ENDPOINT=https://objects.example.com -S3_BUCKET=textbin +S3_ENDPOINT=https://objects.internal.example.com +S3_BUCKET=textbin-prod S3_REGION=us-east-1 -S3_ACCESS_KEY_ID=... -S3_SECRET_ACCESS_KEY=... +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +TEXTBIN_UPLOAD_TMP_DIR=/var/lib/textbin/uploads ``` -Textbin uses path-style object URLs and basic signed PUT, GET, and DELETE -operations. The bucket must exist before the server starts, and the credentials -must be restricted to object operations for that bucket. SeaweedFS and Garage -are suitable self-hosted implementations; verify compatibility before selecting -another provider. +No paste volume is required: -S3 removes the need for persistent local paste storage, but the upload staging -path must still be writable. A paste is journaled in PostgreSQL before its object -is uploaded. Interrupted objects are claimed and removed by the background -cleaner without racing successful paste commits. +```sh +docker volume create textbin-uploads +docker run --rm --user 0:0 \ + --mount type=volume,src=textbin-uploads,dst=/var/lib/textbin/uploads \ + --entrypoint chown ghcr.io/chaba-dev/textbin:0.1.0 \ + -R 1000:1000 /var/lib/textbin/uploads -Do not change an existing installation from local to S3, or from S3 to local, -by changing only environment variables. Storage keys do not identify their -backend, and Textbin does not currently migrate existing objects between -backends. +docker run --name textbin --read-only --restart unless-stopped \ + --network textbin-private --env-file /run/textbin/runtime.env \ + --mount type=volume,src=textbin-uploads,dst=/var/lib/textbin/uploads \ + --publish 127.0.0.1:4000:4000 \ + ghcr.io/chaba-dev/textbin:0.1.0 +``` -## PostgreSQL and storage are one durability boundary +Use the object store's supported CLI from the Textbin network to put, read, and +delete a disposable object using the application credentials. Then perform the +end-to-end paste check below. A bucket-list operation alone is insufficient: +Textbin needs object PUT, GET, and DELETE. Do not log the credentials or probe +contents. -PostgreSQL stores ownership, visibility, expiration, content type, size, -checksum, and the blob storage key. Small UTF-8 textual bodies are stored inline -in PostgreSQL; larger and binary bodies use the selected storage backend. +Never switch an existing installation between local and S3 by changing only the +environment. Storage keys do not identify a backend, and Textbin does not migrate +existing objects. -Back up PostgreSQL and blob storage as a coordinated unit. The safest procedure -is: +## Networking, TLS, and probes -1. stop or quiesce writes; -2. wait for active requests to finish; -3. snapshot PostgreSQL and the local/S3 blob store; -4. resume writes; and -5. regularly restore both snapshots into an isolated environment and verify - paste reads. +### Reverse proxy (recommended) -Independent live snapshots can capture a database row without its object, or an -object without its row, because creation and deletion cross two systems. The -pending-upload journal repairs interrupted live operations; it is not a -replacement for coordinated backups. +Publish `PORT` only to a trusted proxy, ingress, or load balancer. The proxy must: -## Upgrade procedure +- terminate public TLS for `PHX_HOST` and redirect public HTTP to HTTPS; +- preserve the original `Host` header; +- support WebSocket upgrades for `/live/websocket`; +- forward normal HTTP to the private `PORT`; and +- use `/readyz` to select traffic-ready instances. -For each upgrade: +Textbin generates canonical URLs as `https://PHX_HOST` on public port 443. +Non-standard public HTTPS ports are not supported. Textbin does not currently +trust or rewrite `Forwarded` or `X-Forwarded-*` headers. Proxy-set values cannot +override the canonical host or scheme, and request logs see the immediate peer +address. Configure the proxy to replace, not append, forwarded headers anyway, +and do not use them for authorization or abuse decisions. -1. read the release notes and back up PostgreSQL and blob storage; -2. run `/app/bin/migrate` from the new image as a dedicated job; -3. replace application instances using the runtime's normal rollout strategy; -4. verify login, paste creation, and reads from the configured backend; and -5. retain the previous image until the rollout is accepted. +### Direct TLS + +Mount a certificate and key read-only, readable by UID/GID `1000`, then set: + +```dotenv +TLS_CERT_PATH=/run/secrets/textbin/tls.crt +TLS_KEY_PATH=/run/secrets/textbin/tls.key +HTTPS_PORT=4443 +``` + +Map public TCP 443 to `HTTPS_PORT`; the HTTP `PORT` remains enabled for private +probes. Every replica needs a certificate valid for `PHX_HOST`. Replace renewed +files atomically and restart or roll instances so Erlang loads them. Certificate +issuance and renewal are operator responsibilities. + +### Health checks + +| Endpoint | Success | Meaning | +|---|---|---| +| `GET /healthz` | `200 ok` | BEAM and HTTP listener are alive; no dependency check | +| `GET /readyz` | `200 ready` | A bounded `SELECT 1` succeeded against PostgreSQL | + +`/readyz` returns 503 on database failure and never includes connection details. +Use `/healthz` for restart decisions and `/readyz` for rollout and load-balancer +readiness. Neither endpoint verifies blob storage; use the end-to-end paste probe +after deployment and alert on application storage errors. + +## First user and platform administrator + +1. Confirm Postmark delivery and `MAIL_FROM_ADDRESS` before exposing Textbin. +2. Visit `https://PHX_HOST/users/register`, submit the first user's email, and + follow the emailed confirmation/login link. +3. While that confirmed user is logged in, set a password in user settings if API + token creation is needed. +4. Grant platform authority from the running container: + + ```sh + docker exec textbin /app/bin/grant-platform-admin admin@example.com + ``` + +The command accepts exactly one email, requires an existing confirmed, +non-suspended human user, and records a platform audit event. It is also the +audited recovery command when all normal admin access is lost. It uses release +RPC, so execute it against a running application container with its normal +environment; do not edit the database directly. Organization owner/admin roles +do not grant platform authority. + +## Verification + +Check probes first: + +```sh +curl --fail --silent --show-error https://textbin.example.com/healthz +curl --fail --silent --show-error https://textbin.example.com/readyz +``` + +For a full database-and-blob smoke test, use a dedicated operator account with a +password. Keep the returned token out of shell tracing and revoke it afterward: + +```sh +set +x +BASE_URL=https://textbin.example.com +TOKEN="$({ + printf '{"email":"%s","password":"%s","name":"restore-drill"}' \ + 'operator@example.com' "$TEXTBIN_OPERATOR_PASSWORD" +} | curl --fail --silent --show-error \ + -H 'content-type: application/json' --data-binary @- \ + "$BASE_URL/api/v1/auth/tokens" | jq -er '.data.api_token')" + +dd if=/dev/urandom bs=16384 count=1 2>/dev/null > /tmp/textbin-probe.bin +EXPECTED_SHA256="$(sha256sum /tmp/textbin-probe.bin | cut -d' ' -f1)" +PASTE_ID="$(curl --fail --silent --show-error \ + -H "authorization: Bearer $TOKEN" \ + -H 'content-type: application/octet-stream' \ + --data-binary @/tmp/textbin-probe.bin \ + "$BASE_URL/api/v1/pastes" | jq -er '.data.id')" + +curl --fail --silent --show-error \ + -H "authorization: Bearer $TOKEN" \ + "$BASE_URL/api/v1/pastes/$PASTE_ID" \ + | jq -er '.data.data_base64' | base64 -d > /tmp/textbin-probe-restored.bin +test "$(sha256sum /tmp/textbin-probe-restored.bin | cut -d' ' -f1)" = "$EXPECTED_SHA256" + +curl --fail --silent --show-error -X DELETE \ + -H "authorization: Bearer $TOKEN" \ + "$BASE_URL/api/v1/pastes/$PASTE_ID" +curl --fail --silent --show-error -X DELETE \ + -H "authorization: Bearer $TOKEN" "$BASE_URL/api/v1/me/token" +rm -f /tmp/textbin-probe.bin /tmp/textbin-probe-restored.bin +unset TOKEN TEXTBIN_OPERATOR_PASSWORD +``` + +The random 16 KiB binary body exceeds the default inline threshold and therefore +exercises the configured external storage backend. A successful checksum proves +that API authorization, PostgreSQL metadata, object write/read, and integrity +verification all worked. + +## Back up and restore + +PostgreSQL records include object keys, sizes, and SHA-256 checksums. Blob writes +and database commits cross systems, so independent live backups can capture a row +without its object or an object without its row. The pending-upload journal +repairs interrupted live operations; it is not a backup mechanism. + +### Coordinated backup + +1. Record the exact image digest and runtime configuration names (not secret + values). +2. Stop all Textbin instances or block write traffic and let active requests + finish. Confirm no server can create or delete a paste. +3. Back up PostgreSQL using PostgreSQL 17 tooling, for example `pg_dump` in custom + format, and capture its checksum. +4. While writes remain stopped, snapshot/copy `/var/lib/textbin/pastes` or the + complete S3 bucket, including object versions if versioning is enabled. +5. Capture the blob backup's provider snapshot/version identifier and inventory. +6. Resume writes only after both backups have completed. Store both artifacts and + their identifiers as one backup set. + +For example, while writes are stopped, PostgreSQL custom-format and local-storage +backups can be created with PostgreSQL 17 and standard archive tools: + +```sh +umask 077 +BACKUP_DIR=/secure-backups/textbin-2026-08-26T210000Z +PGDATABASE_URL='postgresql://textbin:@postgres:5432/textbin_prod' +install -d -m 0700 "$BACKUP_DIR" +pg_dump --format=custom --file="$BACKUP_DIR/postgres.dump" "$PGDATABASE_URL" +tar --create --file="$BACKUP_DIR/pastes.tar" \ + --directory=/var/lib/textbin/pastes . +sha256sum "$BACKUP_DIR/postgres.dump" "$BACKUP_DIR/pastes.tar" \ + > "$BACKUP_DIR/SHA256SUMS" +unset PGDATABASE_URL +``` + +Run those tools where the durable paste path is mounted read-only. For S3, use a +provider-consistent bucket snapshot or versioned replication operation instead +of `pastes.tar`, then record its immutable identifier and inventory beside the +database dump. + +Upload staging is not durable paste data and need not be backed up. The secret +key is needed to preserve existing signed sessions but belongs in the secret +manager's protected backup, not beside ordinary backup logs. + +### Restore drill + +Run this drill regularly, not only after an incident: + +1. Create an isolated PostgreSQL 17 database and isolated local volume or bucket. + Ensure it cannot receive production traffic or email. +2. Restore the blob snapshot first, preserving every object key. +3. Restore PostgreSQL with `pg_restore --exit-on-error --clean --if-exists` (or + the equivalent for the chosen backup format). +4. Configure the recorded image digest against the restored database and blob + location. Use a new hostname and secret delivery path. +5. Run `/app/bin/migrate` only if intentionally validating an upgrade; otherwise + start the exact backed-up image. +6. Require `/readyz` to return 200. Compare expected database row counts and the + blob inventory with the backup manifest. +7. Select at least one known external paste from the backup and read it through + the API. Compare the returned bytes with its recorded SHA-256 checksum. Run + the binary end-to-end probe from [Verification](#verification) to prove new + writes and reads too. +8. Record the restore duration, checks performed, missing objects, checksum + failures, and cleanup of the isolated environment. + +For an isolated database that already exists and an empty local restore path, +the corresponding restore commands are: + +```sh +umask 077 +BACKUP_DIR=/secure-backups/textbin-2026-08-26T210000Z +RESTORE_DATABASE_URL='postgresql://textbin_restore:@restore-postgres:5432/textbin_restore' +cd "$BACKUP_DIR" +sha256sum --check SHA256SUMS +test -z "$(find /restore/textbin/pastes -mindepth 1 -print -quit)" +tar --extract --file=pastes.tar --directory=/restore/textbin/pastes +pg_restore --exit-on-error --clean --if-exists --no-owner \ + --dbname="$RESTORE_DATABASE_URL" postgres.dump +unset RESTORE_DATABASE_URL +``` -Database migrations are expected to be forward-compatible during a normal -rolling deployment. Downgrading an image does not reverse migrations. Restore a -coordinated pre-upgrade backup when a migration must be rolled back. +Restore an S3 snapshot with the provider's immutable snapshot/version identifier +before `pg_restore`. Keep the restore bucket isolated and preserve object keys. + +A database that starts is not a successful restore. The drill succeeds only when +metadata exists **and** externally stored paste bytes read with the expected +checksum. + +## Upgrades and rollback + +Before every upgrade, read all intervening release notes and complete a +coordinated backup and recent restore drill. + +Single node: + +1. stop the old server; +2. run `/app/bin/migrate` from the exact new image; +3. start the new image with unchanged durable storage and secrets; and +4. require readiness, login, and the end-to-end storage probe before reopening + traffic. + +Rolling deployment: + +1. run one migration job from the new image while old instances remain serving; +2. stop if migration fails; +3. replace instances gradually, adding each only after `/readyz` succeeds; and +4. verify login and external paste create/read before completing the rollout. + +Migrations are designed to be forward-compatible with the previous application +during a normal roll. They are not reversible, and launching an older image after +new migrations is not a supported rollback. If application rollback is unsafe, +stop writes and restore the coordinated pre-upgrade PostgreSQL and blob backup, +then start its recorded image digest. Never restore only PostgreSQL or only blobs. + +## Troubleshooting without exposing secrets + +| Symptom | Safe checks | +|---|---| +| Startup says a required variable is missing | Check that the secret/config key is attached; print key names or presence only, never values | +| Database connection refused or `/readyz` is 503 | Run `pg_isready` from the application network; check DNS, port, TLS policy, role limits, and `replicas × POOL_SIZE` | +| Migration waits or fails | Ensure only the release job is migrating, inspect PostgreSQL advisory locks and migration logs, and do not start the new image | +| Local writes fail | Check mount ownership is `1000:1000`, free space/inodes, read-only flags, and rename/fsync support | +| S3 returns 403 | Check clock synchronization, endpoint/region, path-style support, and object-level policy without printing keys | +| S3 returns 404 | Confirm bucket and endpoint, then check whether database and blob backups came from the same set | +| Uploads fail but small pastes work | Check staging ownership, free space, inode availability, and the 1 MiB request limit | +| Confirmation mail is absent | Check Postmark delivery/activity, verified sender, outbound HTTPS, and recipient suppression state; never log the API token | +| Browser loops or LiveView disconnects | Confirm public HTTPS, `PHX_HOST`, preserved `Host`, WebSocket upgrade support, and proxy idle timeout | +| TLS listener will not start | Check that both PEM paths are regular readable files for UID 1000 and that `HTTPS_PORT` is free | + +Application errors include a request ID where available. Correlate that ID with +proxy and application logs. Sanitize database URLs, authorization headers, API +tokens, cookies, Postmark credentials, and S3 credentials before sharing logs. diff --git a/rfd/0002/IMPLEMENTATION.org b/rfd/0002/IMPLEMENTATION.org index 8aac9c6..9186aef 100644 --- a/rfd/0002/IMPLEMENTATION.org +++ b/rfd/0002/IMPLEMENTATION.org @@ -2,12 +2,12 @@ Implements [[file:README.adoc][RFD 2: Self-hosting documentation]]. -- [ ] A fresh operator can identify every required external dependency and durable +- [X] A fresh operator can identify every required external dependency and durable path from the guide alone. -- [ ] Local-storage and S3-compatible deployments each have a complete configuration +- [X] Local-storage and S3-compatible deployments each have a complete configuration example and verification procedure. -- [ ] The documented migration, admin-bootstrap, backup, restore, and upgrade +- [X] The documented migration, admin-bootstrap, backup, restore, and upgrade commands execute against the published release image. -- [ ] Documentation clearly separates supported runtime contracts from illustrative +- [X] Documentation clearly separates supported runtime contracts from illustrative orchestration examples. -- [ ] A restore drill verifies both metadata and external paste content. +- [X] A restore drill verifies both metadata and external paste content. From dab128a4002d4cdf32b856ad86607bbf4a944e12 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Wed, 26 Aug 2026 21:58:16 +0000 Subject: [PATCH 3/3] fix: harden self-hosting operations Amp-Thread-ID: https://ampcode.com/threads/T-01a03fee-fa37-71e5-abc0-0671407acb56 --- .github/workflows/elixir.yml | 20 +++++- docs/self-hosting.md | 92 ++++++++++++++++++--------- rel/overlays/bin/grant-platform-admin | 8 +-- test/textbin/release_script_test.exs | 46 ++++++++++++++ 4 files changed, 131 insertions(+), 35 deletions(-) create mode 100644 test/textbin/release_script_test.exs diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index e1c6b09..4fa95d3 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -261,8 +261,9 @@ jobs: } trap cleanup EXIT - docker run --detach --name "$container_name" --network host \ + docker run --detach --name "$container_name" --network host --read-only \ --mount "type=bind,src=$tls_dir,dst=/run/secrets/textbin,readonly" \ + --tmpfs /app/tmp:rw,noexec,nosuid,nodev,size=16m,mode=0700,uid=1000,gid=1000 \ -e DATABASE_URL="ecto://${DATABASE_USER}:${DATABASE_PASSWORD}@localhost:5432/textbin_container_test" \ -e SECRET_KEY_BASE="$secret_key_base" \ -e PHX_HOST=localhost \ @@ -288,3 +289,20 @@ jobs: test "$ready" = "true" curl --fail --silent --show-error --output /dev/null http://127.0.0.1:4100/healthz curl --fail --silent --show-error --output /dev/null http://127.0.0.1:4100/readyz + + docker exec "$container_name" /app/bin/textbin rpc ' + {:ok, user} = Textbin.Accounts.register_user(%{email: "admin@example.com"}) + user |> Textbin.Accounts.User.confirm_changeset() |> Textbin.Repo.update!() + :ok + ' + docker exec "$container_name" /app/bin/grant-platform-admin 'Admin@Example.COM' + docker exec "$container_name" /app/bin/textbin rpc ' + user = Textbin.Accounts.get_user_by_email("admin@example.com") + true = user.platform_role == "admin" + Textbin.Repo.get_by!(Textbin.Administration.PlatformAuditEvent, + action: "platform.admin.bootstrap", + actor_kind: "bootstrap", + target_id: user.id + ) + :ok + ' diff --git a/docs/self-hosting.md b/docs/self-hosting.md index fd9f3fd..8233a8d 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -204,10 +204,15 @@ docker run --name textbin --read-only --restart unless-stopped \ --network textbin-private --env-file /run/textbin/runtime.env \ --mount type=volume,src=textbin-pastes,dst=/var/lib/textbin/pastes \ --mount type=volume,src=textbin-uploads,dst=/var/lib/textbin/uploads \ + --tmpfs /app/tmp:rw,noexec,nosuid,nodev,size=16m,mode=0700,uid=1000,gid=1000 \ --publish 127.0.0.1:4000:4000 \ ghcr.io/chaba-dev/textbin:0.1.0 ``` +The private `/app/tmp` tmpfs is required with `--read-only`: the OTP release +launcher writes evaluated runtime configuration there before boot. Keep it +ephemeral because that configuration contains resolved secrets. + The volume must already be writable by UID/GID `1000`. Verify before migration: ```sh @@ -254,6 +259,7 @@ docker run --rm --user 0:0 \ docker run --name textbin --read-only --restart unless-stopped \ --network textbin-private --env-file /run/textbin/runtime.env \ --mount type=volume,src=textbin-uploads,dst=/var/lib/textbin/uploads \ + --tmpfs /app/tmp:rw,noexec,nosuid,nodev,size=16m,mode=0700,uid=1000,gid=1000 \ --publish 127.0.0.1:4000:4000 \ ghcr.io/chaba-dev/textbin:0.1.0 ``` @@ -347,36 +353,62 @@ For a full database-and-blob smoke test, use a dedicated operator account with a password. Keep the returned token out of shell tracing and revoke it afterward: ```sh -set +x -BASE_URL=https://textbin.example.com -TOKEN="$({ - printf '{"email":"%s","password":"%s","name":"restore-drill"}' \ - 'operator@example.com' "$TEXTBIN_OPERATOR_PASSWORD" -} | curl --fail --silent --show-error \ - -H 'content-type: application/json' --data-binary @- \ - "$BASE_URL/api/v1/auth/tokens" | jq -er '.data.api_token')" - -dd if=/dev/urandom bs=16384 count=1 2>/dev/null > /tmp/textbin-probe.bin -EXPECTED_SHA256="$(sha256sum /tmp/textbin-probe.bin | cut -d' ' -f1)" -PASTE_ID="$(curl --fail --silent --show-error \ - -H "authorization: Bearer $TOKEN" \ - -H 'content-type: application/octet-stream' \ - --data-binary @/tmp/textbin-probe.bin \ - "$BASE_URL/api/v1/pastes" | jq -er '.data.id')" - -curl --fail --silent --show-error \ - -H "authorization: Bearer $TOKEN" \ - "$BASE_URL/api/v1/pastes/$PASTE_ID" \ - | jq -er '.data.data_base64' | base64 -d > /tmp/textbin-probe-restored.bin -test "$(sha256sum /tmp/textbin-probe-restored.bin | cut -d' ' -f1)" = "$EXPECTED_SHA256" - -curl --fail --silent --show-error -X DELETE \ - -H "authorization: Bearer $TOKEN" \ - "$BASE_URL/api/v1/pastes/$PASTE_ID" -curl --fail --silent --show-error -X DELETE \ - -H "authorization: Bearer $TOKEN" "$BASE_URL/api/v1/me/token" -rm -f /tmp/textbin-probe.bin /tmp/textbin-probe-restored.bin -unset TOKEN TEXTBIN_OPERATOR_PASSWORD +( + set -euo pipefail + set +x + : "${TEXTBIN_OPERATOR_PASSWORD:?set TEXTBIN_OPERATOR_PASSWORD}" + + BASE_URL=https://textbin.example.com + TOKEN= + PASTE_ID= + + cleanup() { + status=$? + trap - EXIT + set +e + if [[ -n "$TOKEN" && -n "$PASTE_ID" ]]; then + curl --fail --silent --show-error --output /dev/null -X DELETE \ + -H "authorization: Bearer $TOKEN" \ + "$BASE_URL/api/v1/pastes/$PASTE_ID" + fi + if [[ -n "$TOKEN" ]]; then + curl --fail --silent --show-error --output /dev/null -X DELETE \ + -H "authorization: Bearer $TOKEN" "$BASE_URL/api/v1/me/token" + fi + rm -f /tmp/textbin-probe.bin /tmp/textbin-probe-restored.bin + unset TOKEN TEXTBIN_OPERATOR_PASSWORD + exit "$status" + } + trap cleanup EXIT + + TOKEN="$( + jq -cn \ + --arg email 'operator@example.com' \ + --arg password "$TEXTBIN_OPERATOR_PASSWORD" \ + --arg name 'restore-drill' \ + '{email: $email, password: $password, name: $name}' \ + | curl --fail --silent --show-error \ + -H 'content-type: application/json' --data-binary @- \ + "$BASE_URL/api/v1/auth/tokens" \ + | jq -er '.data.api_token' + )" + + dd if=/dev/urandom bs=16384 count=1 2>/dev/null > /tmp/textbin-probe.bin + EXPECTED_SHA256="$(sha256sum /tmp/textbin-probe.bin | cut -d' ' -f1)" + PASTE_ID="$(curl --fail --silent --show-error \ + -H "authorization: Bearer $TOKEN" \ + -H 'content-type: application/octet-stream' \ + --data-binary @/tmp/textbin-probe.bin \ + "$BASE_URL/api/v1/pastes" | jq -er '.data.id')" + + curl --fail --silent --show-error \ + -H "authorization: Bearer $TOKEN" \ + "$BASE_URL/api/v1/pastes/$PASTE_ID" \ + | jq -er '.data.data_base64' \ + | base64 -d > /tmp/textbin-probe-restored.bin + test "$(sha256sum /tmp/textbin-probe-restored.bin | cut -d' ' -f1)" = "$EXPECTED_SHA256" + echo "Textbin external paste integrity verified" +) ``` The random 16 KiB binary body exceeds the default inline threshold and therefore diff --git a/rel/overlays/bin/grant-platform-admin b/rel/overlays/bin/grant-platform-admin index 854c30a..31f726e 100755 --- a/rel/overlays/bin/grant-platform-admin +++ b/rel/overlays/bin/grant-platform-admin @@ -2,11 +2,11 @@ set -eu if [ "$#" -ne 1 ]; then - echo "usage: $0 EMAIL" >&2 - exit 64 + echo "usage: $0 EMAIL" >&2 + exit 64 fi -export TEXTBIN_PLATFORM_ADMIN_EMAIL="$1" +encoded_email=$(printf '%s' "$1" | base64 | tr -d '\n') exec "$(dirname "$0")/textbin" rpc \ - 'Textbin.Release.grant_platform_admin(System.fetch_env!("TEXTBIN_PLATFORM_ADMIN_EMAIL"))' + "email = Base.decode64!(\"$encoded_email\"); case Textbin.Release.grant_platform_admin(email) do {:ok, _result} = success -> success; {:error, reason} -> raise \"grant-platform-admin failed: #{inspect(reason)}\" end" diff --git a/test/textbin/release_script_test.exs b/test/textbin/release_script_test.exs new file mode 100644 index 0000000..aef4e25 --- /dev/null +++ b/test/textbin/release_script_test.exs @@ -0,0 +1,46 @@ +defmodule Textbin.ReleaseScriptTest do + use ExUnit.Case, async: true + + @script "rel/overlays/bin/grant-platform-admin" + + setup do + directory = Path.join(System.tmp_dir!(), "textbin-release-script-#{System.unique_integer()}") + File.mkdir_p!(directory) + File.cp!(@script, Path.join(directory, "grant-platform-admin")) + + File.write!(Path.join(directory, "textbin"), """ + #!/bin/sh + printf '%s\n' "$@" + exit "${FAKE_EXIT_STATUS:-0}" + """) + + File.chmod!(Path.join(directory, "grant-platform-admin"), 0o700) + File.chmod!(Path.join(directory, "textbin"), 0o700) + on_exit(fn -> File.rm_rf!(directory) end) + + %{script: Path.join(directory, "grant-platform-admin")} + end + + test "transports the email in the RPC expression without relying on server environment", %{ + script: script + } do + email = ~S(admin+"quoted"@example.com) + + assert {output, 0} = System.cmd(script, [email]) + assert ["rpc", expression] = String.split(output, "\n", trim: true) + assert expression =~ "Base.decode64!(\"#{Base.encode64(email)}\")" + assert expression =~ "raise \"grant-platform-admin failed:" + refute expression =~ email + refute expression =~ "System.fetch_env!" + end + + test "returns the release RPC exit status", %{script: script} do + assert {_output, 17} = + System.cmd(script, ["admin@example.com"], env: [{"FAKE_EXIT_STATUS", "17"}]) + end + + test "rejects an invalid argument count", %{script: script} do + assert {output, 64} = System.cmd(script, [], stderr_to_stdout: true) + assert output =~ "usage:" + end +end