diff --git a/.gitignore b/.gitignore index baf21b4a..6a16f7d5 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,6 @@ improvements bots/gateway-files/ .DS_Store Dockerfile.patched-hummingbot + +# EMQX broker auth bootstrap (generated by `make emqx-auth`; contains BROKER_PASSWORD) +.emqx/ diff --git a/Makefile b/Makefile index 295bed15..b57b7848 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup run deploy stop install uninstall build install-pre-commit tailscale-status doctor reset +.PHONY: setup run deploy stop install uninstall build install-pre-commit tailscale-status doctor reset emqx-auth emqx-auth-reset emqx-audit SETUP_SENTINEL := .setup-complete @@ -11,7 +11,7 @@ $(SETUP_SENTINEL): # Run locally (dev mode) # When TAILSCALE_ENABLED=true: installs Tailscale if needed, connects, configures tailscale serve, # then binds uvicorn to 127.0.0.1 only (tailscale serve exposes port 8000 on the tailnet) -run: +run: emqx-auth docker compose up emqx postgres -d @set -a; [ -f .env ] && . ./.env; set +a; \ if [ "$${TAILSCALE_ENABLED:-false}" = "true" ]; then \ @@ -34,7 +34,7 @@ run: # Deploy with Docker # When TAILSCALE_ENABLED=true: adds the Tailscale sidecar compose override -deploy: $(SETUP_SENTINEL) +deploy: $(SETUP_SENTINEL) emqx-auth @set -a; [ -f .env ] && . ./.env; set +a; \ if [ "$${TAILSCALE_ENABLED:-false}" = "true" ]; then \ echo "[INFO] Deploying with Tailscale sidecar..."; \ @@ -48,6 +48,90 @@ deploy: $(SETUP_SENTINEL) doctor: @chmod +x doctor.sh && ./doctor.sh +EMQX_AUTH_FILE := .emqx/auth-bootstrap.csv + +# Generate the EMQX built-in-database bootstrap file from the broker credentials in .env. +# EMQX ships with anonymous MQTT enabled; this seeds the one account the API and the bots +# use so the broker can reject everything else. The file holds a plaintext password, so it +# is written 0600 and gitignored. +# +# is_superuser is deliberately false: EMQX superusers bypass authorization entirely, which +# would make emqx/acl.conf dead config. +# +# NOTE: EMQX imports the bootstrap file only for users that do not already exist. Changing +# BROKER_PASSWORD in .env therefore has no effect on a broker whose emqx-data volume already +# has the account — run `make emqx-auth-reset` to drop the volume and re-seed. +emqx-auth: + @set -a; [ -f .env ] && . ./.env; set +a; \ + user="$${BROKER_USERNAME:-admin}"; pass="$${BROKER_PASSWORD:-password}"; \ + bad=0; \ + case "$$user$$pass" in *,*) bad=1 ;; esac; \ + [ "$$(printf '%s' "$$user$$pass" | wc -l)" -eq 0 ] || bad=1; \ + if [ "$$bad" -eq 1 ]; then \ + echo "[ERROR] BROKER_USERNAME/BROKER_PASSWORD cannot contain a comma or newline" \ + "— it would corrupt the CSV bootstrap file's field structure." >&2; \ + exit 1; \ + fi; \ + mkdir -p $(dir $(EMQX_AUTH_FILE)); \ + printf 'user_id,password,is_superuser\n%s,%s,false\n' "$$user" "$$pass" > $(EMQX_AUTH_FILE); \ + chmod 600 $(EMQX_AUTH_FILE); \ + echo "[INFO] Wrote $(EMQX_AUTH_FILE) for broker user $$user" + +# Broker container to audit. Override to check another deployment: +# make emqx-audit EMQX_CONTAINER= +EMQX_CONTAINER ?= hummingbot-broker + +# Audit the broker's security posture and check for persistence. +# +# EMQX's rule engine can issue authenticated HTTP requests to internal services, so a rule +# or connector nobody added is a backdoor, not a curiosity — and it survives restarts in +# cluster.hocon. Installing one requires a dashboard session, which is why the well-known +# admin/public default mattered. This prints everything needed to answer "is anything here +# that we did not put here", in one command. +emqx-audit: + @echo "── listeners ────────────────────────────────────────────────" + @docker exec $(EMQX_CONTAINER) /opt/emqx/bin/emqx ctl listeners 2>/dev/null \ + | grep -E "^[a-z]|listen_on|current_conn" || echo " broker not running" + @echo "── published ports (host side) ──────────────────────────────" + @docker port $(EMQX_CONTAINER) 2>/dev/null || true + @echo "── authentication (empty list == anonymous allowed) ─────────" + @docker exec $(EMQX_CONTAINER) /opt/emqx/bin/emqx ctl conf show authentication 2>/dev/null || true + @echo "── authorization (want no_match = deny) ─────────────────────" + @docker exec $(EMQX_CONTAINER) /opt/emqx/bin/emqx ctl conf show authorization 2>/dev/null || true + @echo "── ACL rules in force ───────────────────────────────────────" + @docker exec $(EMQX_CONTAINER) sh -c 'grep -E "^\{" /opt/emqx/etc/acl.conf' 2>/dev/null || true + @echo "── PERSISTENCE: rules / actions / connectors / bridges ──────" + @echo " A rule here can make the broker issue authenticated HTTP requests" + @echo " into internal services. Anything you did not add is a backdoor." + @docker exec $(EMQX_CONTAINER) sh -c \ + "awk '/^(actions|connectors|bridges|rule_engine) \\{/{p=1} p{print} /^\\}/{if(p){p=0;print \"\"}}' \ + /opt/emqx/data/configs/cluster.hocon 2>/dev/null \ + | grep -vE 'created_at|last_modified|metadata'" \ + 2>/dev/null | sed 's/^/ /' || true + @docker exec $(EMQX_CONTAINER) sh -c \ + "grep -qE '^(actions|connectors|bridges) \\{|rules \\{' /opt/emqx/data/configs/cluster.hocon 2>/dev/null" \ + && echo " ^^ REVIEW THE ABOVE — stock deployments have none of these." \ + || echo " none — no rules, actions, connectors or bridges configured." + +# Compose derives the project name from COMPOSE_PROJECT_NAME if set, else the lowercased +# directory name -- $(notdir $(CURDIR)) alone gets this wrong for any directory with an +# uppercase letter, silently making the volume filter below match nothing. +COMPOSE_PROJECT := $(shell echo "$${COMPOSE_PROJECT_NAME:-$(notdir $(CURDIR))}" | tr '[:upper:]' '[:lower:]') + +# Rotate the broker credentials: wipe the EMQX state volume so the bootstrap file is +# re-imported with the current .env values, and the dashboard's default password (a +# separate credential, BROKER_DASHBOARD_PASSWORD) is re-applied too -- both live in the +# same mnesia data dir. Retained messages and broker state are lost; bots and the API +# reconnect on their own. The volume is matched by compose labels rather than by name, +# since other compose projects on the same host also have emqx volumes. +emqx-auth-reset: emqx-auth + docker compose rm -sf emqx + @docker volume ls -q \ + --filter "label=com.docker.compose.project=$(COMPOSE_PROJECT)" \ + --filter "label=com.docker.compose.volume=emqx-data" \ + | xargs -r docker volume rm + docker compose up -d emqx + TAILSCALE_CONTAINER := hummingbot-tailscale # Show Tailscale connection status (Docker sidecar or local install) diff --git a/README.md b/README.md index 1fb0cc77..78fca550 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,34 @@ After hummingbot-api is running, these services are available: | **API** | http://localhost:8000 | http://hummingbot-api:8000 | REST API | | **Swagger UI** | http://localhost:8000/docs | http://hummingbot-api:8000/docs | Interactive API documentation | | **PostgreSQL** | localhost:5432 | — | Database | -| **EMQX** | localhost:1883 | — | MQTT broker | -| **EMQX Dashboard** | http://localhost:18083 | — | Broker admin (admin/public) | +| **EMQX** | 127.0.0.1:1883 | — | MQTT broker (auth required, loopback-only) | +| **EMQX Dashboard** | http://127.0.0.1:18083 | — | Broker admin (`admin` / `BROKER_DASHBOARD_PASSWORD`) | + +> **Broker access.** The broker requires a username and password and is published on the loopback +> interface only — bot containers run with `network_mode: host` and reach it at `127.0.0.1:1883`, +> while the API reaches it in-network as `emqx:1883`. Credentials come from `BROKER_USERNAME` / +> `BROKER_PASSWORD` in `.env`; `make deploy` seeds them into the broker via `make emqx-auth`. +> To change them afterwards, edit `.env` and run `make emqx-auth-reset` — EMQX only imports the +> bootstrap file for accounts it does not already have. +> +> The dashboard login is a **separate** credential, `BROKER_DASHBOARD_PASSWORD` — deliberately +> not the same value as `BROKER_PASSWORD`, since that one is written into every bot instance's +> `conf_client.yml` and the dashboard grants full broker admin (rules, connectors), not just the +> scoped MQTT access `emqx/acl.conf` gives `BROKER_PASSWORD`. Like the MQTT bootstrap account, +> EMQX only applies it on first init, so changing it in `.env` also needs `make emqx-auth-reset` +> (it wipes the same `emqx-data` volume, rotating both passwords together). +> +> The broker also denies every topic outside `hbot/#` and `hummingbot-api/response/#` +> (`emqx/acl.conf`), so a leaked broker credential cannot be used to read the whole bus or to +> drive the rule engine. Run **`make emqx-audit`** to print the broker's listeners, auth, +> authorization and any rules, actions, connectors or bridges — a rule nobody added can make +> the broker issue authenticated HTTP requests into internal services, and it survives +> restarts. Use `make emqx-audit EMQX_CONTAINER=` to check another deployment. + +> **Ports.** The API (`8000`) and Postgres (`5432`) also bind to `127.0.0.1` by default. Set +> `API_BIND` in `.env` if something off-box must reach the API — prefer a specific interface +> over `0.0.0.0`; with the Tailscale overlay, `API_BIND=` keeps MagicDNS working +> without publishing the API to the internet. PostgreSQL and EMQX are bound to `127.0.0.1` only — they're never reachable from another machine, on the tailnet or otherwise. Bot containers still reach @@ -152,10 +178,12 @@ TAILSCALE_ENABLED=true TAILSCALE_AUTH_KEY=tskey-auth-... TAILSCALE_HOSTNAME=hummingbot-api # MagicDNS hostname on your tailnet -# Docker port binding for hummingbot-api:8000 (set automatically by setup.sh: -# 127.0.0.1 when Tailscale is enabled, 0.0.0.0 otherwise) — see -# docker-compose.yml / docker-compose.tailscale.yml -API_BIND_HOST=127.0.0.1 +# Published-port bind addresses (both default to 127.0.0.1 in docker-compose.yml). +# Widen API_BIND only if something off-box must reach the API directly — with the +# Tailscale overlay, API_BIND= keeps MagicDNS working without ever +# publishing the API to the internet. See docker-compose.yml / docker-compose.tailscale.yml +# API_BIND=127.0.0.1 +# DB_BIND=127.0.0.1 ``` Edit `.env` and restart with `make deploy` to apply changes. @@ -200,7 +228,7 @@ When `TAILSCALE_ENABLED=true`, this automatically runs: docker compose -f docker-compose.yml -f docker-compose.tailscale.yml up -d ``` -A Tailscale sidecar container joins your tailnet using `network_mode: host`. `setup.sh` also writes `API_BIND_HOST=127.0.0.1` to `.env`, so `hummingbot-api`'s port 8000 is bound to loopback only — it does not sit on any public interface. The sidecar's `tailscale serve` config (`tailscale-serve.json`) is what makes it reachable again: it forwards the tailnet IP's `:8000` to `127.0.0.1:8000`, so the API ends up reachable at `http://hummingbot-api:8000` from any device on the same tailnet, and *only* from the tailnet. +A Tailscale sidecar container joins your tailnet using `network_mode: host`. `hummingbot-api`'s port 8000 is bound to loopback only by default (`API_BIND` in `.env` — see [Configuration](#configuration)), so it does not sit on any public interface. The sidecar's `tailscale serve` config (`tailscale-serve.json`, applied automatically via `TS_SERVE_CONFIG` when the sidecar starts — no `tailscale serve` command needed) is what makes it reachable again: it forwards the tailnet IP's `:8000` to `127.0.0.1:8000`, so the API ends up reachable at `http://hummingbot-api:8000` from any device on the same tailnet, and *only* from the tailnet. ### Connecting MCP tools via Tailscale diff --git a/docker-compose.tailscale.yml b/docker-compose.tailscale.yml index e96c4105..22e3e8ed 100644 --- a/docker-compose.tailscale.yml +++ b/docker-compose.tailscale.yml @@ -2,13 +2,18 @@ # docker compose -f docker-compose.yml -f docker-compose.tailscale.yml up -d # # network_mode: host lets the Tailscale daemon create a real tailscale0 interface -# on the host. hummingbot-api's port 8000 is bound to 127.0.0.1 only whenever -# this overlay is used (setup.sh writes API_BIND_HOST=127.0.0.1 to .env), so -# TS_SERVE_CONFIG below is what actually makes port 8000 reachable on the -# tailnet — it forwards the tailnet IP's :8000 to 127.0.0.1:8000, which this -# sidecar can reach directly because it shares the host's network namespace. -# Without this, the API would be reachable on the tailnet AND still sitting -# on every other host interface at the same time. +# on the host. hummingbot-api's port 8000 is bound to 127.0.0.1 by default (see +# API_BIND in docker-compose.yml / .env), so TS_SERVE_CONFIG below — which +# forwards the tailnet IP's :8000 to 127.0.0.1:8000 via tailscale-serve.json, +# reachable directly because this sidecar shares the host's network namespace — +# is what's meant to make port 8000 reachable on the tailnet without ever +# putting the API on a public interface. +# +# `make deploy` does not itself invoke `tailscale serve`; TS_SERVE_CONFIG applies +# the same config declaratively when the sidecar starts, and both the tailnet IP +# and MagicDNS name have been verified to reach a loopback-bound port through it. +# If you'd rather not depend on the sidecar applying serve config at all — e.g. +# a deploy that skips this overlay — set API_BIND= in .env instead. # # The base docker-compose.yml emqx-bridge network is unaffected — emqx and # postgres still communicate with hummingbot-api normally via that bridge. diff --git a/docker-compose.yml b/docker-compose.yml index ef383885..b760ed27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,12 @@ services: image: hummingbot/hummingbot-api:latest restart: unless-stopped ports: - # Bound to 127.0.0.1 automatically when Tailscale is enabled (setup.sh - # writes API_BIND_HOST=127.0.0.1 to .env) — the tailnet sidecar's - # `tailscale serve` (docker-compose.tailscale.yml) then forwards the - # tailnet's port 8000 to this loopback bind, so the API is never on a - # public interface at the same time. Left as 0.0.0.0 by default so a - # non-Tailscale deploy is unaffected. - - "${API_BIND_HOST:-0.0.0.0}:8000:8000" + # SEC: loopback by default — this API holds exchange credentials and can move funds, + # and it was being scanned by external hosts when published on 0.0.0.0. Set API_BIND + # in .env to widen it deliberately, and prefer a specific interface over 0.0.0.0: + # with the Tailscale overlay, API_BIND= keeps MagicDNS working without + # offering the API to the public internet. + - "${API_BIND:-127.0.0.1}:8000:8000" volumes: - ./bots:/hummingbot-api/bots - /var/run/docker.sock:/var/run/docker.sock @@ -59,22 +58,43 @@ services: - EMQX_CLUSTER__DISCOVERY_STRATEGY=static - EMQX_CLUSTER__STATIC__SEEDS=[emqx@node1.emqx.local] - EMQX_LOADED_PLUGINS="emqx_recon,emqx_retainer,emqx_management,emqx_dashboard" + # SEC: require username/password on every MQTT connection. EMQX ships with + # `authentication = []` (anonymous allowed), which let anyone able to reach 1883 + # subscribe to `hbot/#` and publish `hbot//stop` to halt a live bot. + # Users are seeded from the bootstrap file generated by `make emqx-auth` out of + # BROKER_USERNAME/BROKER_PASSWORD in .env. + - EMQX_AUTHENTICATION__1__MECHANISM=password_based + - EMQX_AUTHENTICATION__1__BACKEND=built_in_database + - EMQX_AUTHENTICATION__1__USER_ID_TYPE=username + - EMQX_AUTHENTICATION__1__BOOTSTRAP_FILE=/opt/emqx/etc/auth-bootstrap.csv + - EMQX_AUTHENTICATION__1__BOOTSTRAP_TYPE=plain + # Dashboard admin password (default is the well-known admin/public). Deliberately + # NOT BROKER_PASSWORD: that credential is handed to every bot instance's + # conf_client.yml, and the dashboard grants full broker admin (rules, connectors), + # not just the scoped MQTT access BROKER_PASSWORD is meant for. + - EMQX_DASHBOARD__DEFAULT_PASSWORD=${BROKER_DASHBOARD_PASSWORD:-password} + # SEC: deny topics no rule matches. Stock EMQX is no_match=allow with an ACL + # ending in `{allow, all}`, so authentication alone still let any client read + # `hbot/#` — every bot's orders, fills and balances. See emqx/acl.conf. + - EMQX_AUTHORIZATION__NO_MATCH=deny volumes: - emqx-data:/opt/emqx/data - emqx-log:/opt/emqx/log - emqx-etc:/opt/emqx/etc - # Loopback-only: hummingbot-api reaches emqx over the emqx-bridge docker - # network, and bot containers (launched with network_mode=host) reach - # 127.0.0.1 just as well as 0.0.0.0 — nothing external ever needs these, - # including the admin dashboard on 18083 (default creds admin/public). + # Generated by `make emqx-auth`; gitignored because it holds the broker password. + - ./.emqx/auth-bootstrap.csv:/opt/emqx/etc/auth-bootstrap.csv:ro + # Deny-by-default ACL, replacing the stock `{allow, all}` one. + - ./emqx/acl.conf:/opt/emqx/etc/acl.conf:ro ports: + # SEC: loopback-only. Bot containers run with network_mode="host" and reach the + # broker at 127.0.0.1:1883; hummingbot-api reaches it in-network as emqx:1883. + # Neither path needs the broker published on a routable interface — and with the + # Tailscale overlay in play, 0.0.0.0 meant "reachable from the whole tailnet". - "127.0.0.1:1883:1883" # mqtt:tcp - - "127.0.0.1:8883:8883" # mqtt:tcp:ssl - - "127.0.0.1:8083:8083" # mqtt:ws - - "127.0.0.1:8084:8084" # mqtt:ws:ssl - - "127.0.0.1:8081:8081" # http:management - "127.0.0.1:18083:18083" # http:dashboard - - "127.0.0.1:61613:61613" # web-stomp gateway + # 8883 (mqtt:ssl), 8083/8084 (mqtt:ws), 8081 (http:management) and 61613 (web-stomp) + # are not used by this project — conf_client.yml sets mqtt_ssl: false and the API + # speaks plain TCP MQTT. Left unpublished to keep the attack surface minimal. networks: emqx-bridge: aliases: @@ -102,7 +122,9 @@ services: # Loopback-only: only the hummingbot-api container talks to postgres, # and it does so over the emqx-bridge docker network, not this port. ports: - - "127.0.0.1:5432:5432" + # SEC: loopback by default. Only `make run` (source mode, uvicorn on the host) needs + # this published at all; the containerised API reaches Postgres in-network. + - "${DB_BIND:-127.0.0.1}:5432:5432" networks: - emqx-bridge healthcheck: diff --git a/doctor.sh b/doctor.sh index 7c0a8d9d..caae384d 100755 --- a/doctor.sh +++ b/doctor.sh @@ -111,14 +111,26 @@ listening_binds() { fi } +# Strips the trailing ":" off a "host:port" bind address. +bind_host() { printf '%s' "${1%:*}"; } + is_public_bind() { - local host="${1%:*}" + local host + host="$(bind_host "$1")" case "$host" in 0.0.0.0|"*"|"::"|"[::]"|"") return 0 ;; *) return 1 ;; esac } +# Tailscale's CGNAT range is 100.64.0.0/10 -- 100.64.x.x through 100.127.x.x. +is_tailscale_ip() { + local ip="$1" o1 o2 rest + [[ "$ip" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+\.[0-9]+$ ]] || return 1 + o1="${BASH_REMATCH[1]}"; o2="${BASH_REMATCH[2]}" + [ "$o1" -eq 100 ] && [ "$o2" -ge 64 ] && [ "$o2" -le 127 ] +} + # ── Header ─────────────────────────────────────────── echo "" @@ -177,7 +189,7 @@ else HB_CONFIG_PASSWORD="$(env_get CONFIG_PASSWORD)" TS_ENABLED="$(env_get TAILSCALE_ENABLED)" TS_HOSTNAME="$(env_get TAILSCALE_HOSTNAME)" - BIND_HOST="$(env_get API_BIND_HOST)" + BIND_HOST="$(env_get API_BIND)" GATEWAY_PASSPHRASE="$(env_get GATEWAY_PASSPHRASE)" # Credentials that used to be shipped as defaults. This API can place @@ -213,14 +225,17 @@ else row ok "GATEWAY_PASSPHRASE" "set" fi - if [ "$TS_ENABLED" = "true" ]; then - if [ "$BIND_HOST" = "127.0.0.1" ]; then - row ok "API_BIND_HOST" "127.0.0.1 (Tailscale serve exposes 8000 on the tailnet)" - else - row fail "API_BIND_HOST" "TAILSCALE_ENABLED=true but API_BIND_HOST=${BIND_HOST:-unset} — port 8000 stays published on every interface alongside the tailnet. Set API_BIND_HOST=127.0.0.1 and \`make deploy\`" - fi + # docker-compose.yml defaults API_BIND to 127.0.0.1 whether or not Tailscale + # is enabled, so unset is always the safe case here -- only an explicit, + # wider value is worth a second look. + if [ -z "$BIND_HOST" ] || [ "$BIND_HOST" = "127.0.0.1" ]; then + row ok "API_BIND" "127.0.0.1 (default) — port 8000 is loopback-only" + elif [ "$TS_ENABLED" = "true" ] && is_tailscale_ip "$BIND_HOST"; then + row ok "API_BIND" "$BIND_HOST looks like a tailscale IP — the documented way to expose 8000 on the tailnet without the sidecar" + elif [ "$TS_ENABLED" = "true" ]; then + row warn "API_BIND" "set to $BIND_HOST, which is not in Tailscale's 100.64.0.0/10 range — port 8000 may be reachable somewhere other than the tailnet" else - row ok "TAILSCALE_ENABLED" "false — port 8000 is published on ${BIND_HOST:-0.0.0.0}" + row warn "API_BIND" "set to $BIND_HOST — port 8000 is published beyond localhost. Fine on a trusted LAN, risky on a public host" fi fi @@ -296,7 +311,14 @@ elif [ -n "$api_public" ] && [ "${TS_ENABLED:-}" = "true" ]; then elif [ -n "$api_public" ]; then row warn "API (8000)" "bound to $api_public — fine on a trusted LAN, exposed on a public VPS. Enable Tailscale, or firewall the port" else - row ok "API (8000)" "127.0.0.1 only" + # Not a wildcard bind, but not necessarily 127.0.0.1 either -- API_BIND= + # (the documented way to expose 8000 on the tailnet without the sidecar) binds here too. + single_host="$(bind_host "$(printf '%s\n' "$api_binds" | head -1)")" + if [ "$single_host" = "127.0.0.1" ] || [ "$single_host" = "[::1]" ]; then + row ok "API (8000)" "127.0.0.1 only" + else + row ok "API (8000)" "bound to $single_host only — a specific, non-wildcard interface" + fi fi echo "" @@ -326,12 +348,15 @@ else row fail "Tailnet" "not connected — check TAILSCALE_AUTH_KEY (keys expire) and re-run \`make deploy\`" fi # Joining the tailnet is not the same as being reachable on it: with - # API_BIND_HOST=127.0.0.1, `tailscale serve` is the only thing that - # forwards the tailnet's :8000 anywhere. - if ts_serve="$($TS_EXEC serve status 2>&1)" && printf '%s' "$ts_serve" | grep -q "8000"; then + # API_BIND left at its 127.0.0.1 default, TS_SERVE_CONFIG (tailscale + # serve) is what forwards the tailnet's :8000 to the loopback bind -- + # skip this check if API_BIND is set directly to a tailscale IP instead. + if [ -n "$BIND_HOST" ] && is_tailscale_ip "$BIND_HOST"; then + row ok "Serve (port 8000)" "skipped — API_BIND=$BIND_HOST binds the API directly, without relying on tailscale serve" + elif ts_serve="$($TS_EXEC serve status 2>&1)" && printf '%s' "$ts_serve" | grep -q "8000"; then row ok "Serve (port 8000)" "proxied to the tailnet as http://${TS_HOSTNAME:-hummingbot-api}:8000" else - row fail "Serve (port 8000)" "the node is on the tailnet but port 8000 is not proxied — with API_BIND_HOST=127.0.0.1 nothing can reach the API at all. Check tailscale-serve.json is mounted, then \`make deploy\`" + row fail "Serve (port 8000)" "the node is on the tailnet but port 8000 is not proxied — with API_BIND=127.0.0.1 nothing can reach the API at all. Check tailscale-serve.json is mounted, then \`make deploy\`" fi fi fi diff --git a/emqx/acl.conf b/emqx/acl.conf new file mode 100644 index 00000000..46479724 --- /dev/null +++ b/emqx/acl.conf @@ -0,0 +1,32 @@ +%%-------------------------------------------------------------------------------- +%% hummingbot-api broker ACL — deny by default. +%% +%% Paired with EMQX_AUTHORIZATION__NO_MATCH=deny in docker-compose.yml. Stock EMQX +%% ships this file ending in `{allow, all}` with no_match = allow, which is why an +%% authenticated (or, before auth was enabled, anonymous) client could subscribe to +%% `hbot/#` and read every bot's order flow. +%% +%% NOTE: the broker account must NOT be a superuser — EMQX superusers bypass +%% authorization entirely. The bootstrap file written by `make emqx-auth` sets +%% is_superuser=false for exactly this reason. +%% +%% Rules are evaluated top to bottom; first match wins. +%%-------------------------------------------------------------------------------- + +%% The dashboard's own client needs broker telemetry. +{allow, {username, {re, "^dashboard$"}}, subscribe, ["$SYS/#"]}. + +%% Nobody gets the firehose or broker internals. Kept above the grant below so a +%% wildcard subscription can never be satisfied by it. +{deny, all, subscribe, ["$SYS/#", {eq, "#"}, {eq, "+/#"}]}. + +%% The API and every bot instance share one account. Bots publish telemetry and +%% subscribe to their command topics; the API does the inverse. Both live entirely +%% within these two prefixes -- kept in sync by hand with the topic strings in +%% utils/mqtt_manager.py (no shared constant ties the two together, so a renamed +%% or added topic there needs a matching edit here). +{allow, all, all, ["hbot/#", "hummingbot-api/response/#"]}. + +%% Everything else is refused — including the arbitrary topics a rule-engine +%% backdoor would need to publish to in order to fire. +{deny, all}. diff --git a/services/docker_service.py b/services/docker_service.py index 426f6be5..c6f346ca 100644 --- a/services/docker_service.py +++ b/services/docker_service.py @@ -248,6 +248,16 @@ def create_hummingbot_instance(self, config: V2ControllerDeployment): client_config = fs_util.read_yaml_file(conf_file_path) client_config['instance_id'] = instance_name + # SEC: the broker rejects anonymous connections (see the EMQX_AUTHENTICATION__* block in + # docker-compose.yml), so every instance has to present the shared broker credentials — + # the credential templates under bots/credentials/ ship with empty mqtt_username/password. + # mqtt_host is deliberately left untouched: this container runs with network_mode="host" + # and must reach the broker on the host loopback, not at the compose DNS name the API uses. + mqtt_bridge = client_config.get('mqtt_bridge') or {} + mqtt_bridge['mqtt_username'] = settings.broker.username + mqtt_bridge['mqtt_password'] = settings.broker.password + client_config['mqtt_bridge'] = mqtt_bridge + # SEC-048: point the instance at the secured (mTLS) Gateway and give it the shared # cert set. Cert keys are decrypted inside the container with CONFIG_PASSWORD, so this # is only enabled when a config password is set. Generation is idempotent: the instance diff --git a/setup.sh b/setup.sh index 37e13f71..62d20277 100755 --- a/setup.sh +++ b/setup.sh @@ -488,14 +488,29 @@ if prompt_yes_no "Use Tailscale for secure private networking? [y/N]: " "n"; the TAILSCALE_ENABLED=true fi -# Docker Compose reads this same .env for interpolation (it auto-loads a -# file literally named .env from the project directory) — so setting this -# here is what makes docker-compose.yml bind hummingbot-api's port 8000 to -# loopback instead of every interface once Tailscale is handling access. -API_BIND_HOST="0.0.0.0" -if [ "$TAILSCALE_ENABLED" = true ]; then - API_BIND_HOST="127.0.0.1" -fi +# Broker credentials are never typed by the user — the API and the bots are the only clients — +# so generate strong ones instead of shipping well-known defaults. Rotating BROKER_PASSWORD +# later requires `make emqx-auth-reset`, since EMQX only imports the bootstrap file for users +# it does not have. +# +# Two separate passwords, deliberately: BROKER_PASSWORD is the MQTT client credential handed +# to every bot instance's conf_client.yml, while BROKER_DASHBOARD_PASSWORD only unlocks the +# EMQX web dashboard (full broker admin — rules, connectors, the lot). Sharing one password +# between them would mean a single leaked bot config grants dashboard admin, not just the +# scoped MQTT access emqx/acl.conf intends. +# +# EMQX rejects single-character-class passwords for the dashboard, so compose the value from +# letters and digits explicitly rather than trusting a random alphanumeric draw to contain both +# — applied to both passwords here for one code path instead of two. +# `head -c N` exits as soon as it has its N bytes, so `tr` gets SIGPIPE on its next write — +# under `set -euo pipefail` that would otherwise abort setup before .env is written. +gen_password() { + printf '%s%s' \ + "$(LC_ALL=C tr -dc 'A-Za-z' < /dev/urandom | head -c 24 || true)" \ + "$(LC_ALL=C tr -dc '0-9' < /dev/urandom | head -c 8 || true)" +} +BROKER_PASSWORD="$(gen_password)" +BROKER_DASHBOARD_PASSWORD="$(gen_password)" cat > .env << EOF # Hummingbot API Configuration @@ -508,11 +523,18 @@ DEBUG_MODE=false BROKER_HOST=localhost BROKER_PORT=1883 BROKER_USERNAME=admin -BROKER_PASSWORD=password +BROKER_PASSWORD=$BROKER_PASSWORD +BROKER_DASHBOARD_PASSWORD=$BROKER_DASHBOARD_PASSWORD # Database (auto-configured by docker-compose) DATABASE_URL=postgresql+asyncpg://hbot:hummingbot-api@localhost:5432/hummingbot_api +# Published-port bind addresses. Both default to 127.0.0.1 in docker-compose.yml. +# Widen API_BIND only if something off-box must reach the API, and prefer a specific +# interface over 0.0.0.0 — e.g. API_BIND= with the Tailscale overlay. +# API_BIND=127.0.0.1 +# DB_BIND=127.0.0.1 + # Gateway (optional). Reuses CONFIG_PASSWORD rather than a hardcoded default: # this passphrase unlocks Gateway's DEX wallet keys, and "admin" is the first # thing anyone who reaches the port will try. @@ -526,11 +548,6 @@ BOTS_PATH=$(pwd) TAILSCALE_ENABLED=$TAILSCALE_ENABLED TAILSCALE_AUTH_KEY=$TAILSCALE_AUTH_KEY TAILSCALE_HOSTNAME=$TAILSCALE_HOSTNAME - -# Docker port binding for hummingbot-api:8000 — 127.0.0.1 when Tailscale is -# enabled (tailscale serve then proxies the tailnet to it), 0.0.0.0 otherwise. -# See docker-compose.yml / docker-compose.tailscale.yml. -API_BIND_HOST=$API_BIND_HOST EOF # Holds the API password, the config/Gateway passphrase and (when set) a diff --git a/utils/mqtt_manager.py b/utils/mqtt_manager.py index 3495eadb..b4c5eaec 100644 --- a/utils/mqtt_manager.py +++ b/utils/mqtt_manager.py @@ -47,7 +47,9 @@ def __init__(self, host: str, port: int, username: str, password: str): # RPC response tracking self._pending_responses: Dict[str, asyncio.Future] = {} # reply_to_topic: future - # Subscriptions to restore on reconnect + # Subscriptions to restore on reconnect. + # These topic prefixes are also hardcoded in emqx/acl.conf's broker ACL -- + # renaming or adding a prefix here needs a matching edit there. self._subscriptions = [ ("hbot/+/log", 1), # Log messages ("hbot/+/notify", 1), # Notifications