Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
90 changes: 87 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 \
Expand All @@ -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..."; \
Expand All @@ -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=<name>
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)
Expand Down
42 changes: 35 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name>` 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=<tailscale-ip>` 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
Expand Down Expand Up @@ -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=<tailscale-ip> 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.
Expand Down Expand Up @@ -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

Expand Down
19 changes: 12 additions & 7 deletions docker-compose.tailscale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<tailscale-ip> 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.
Expand Down
56 changes: 39 additions & 17 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<tailscale-ip> 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
Expand Down Expand Up @@ -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/<instance_id>/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:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading