From 90bb97cca801d3a3adddca83fb54d38670f09699 Mon Sep 17 00:00:00 2001 From: Yann Autissier Date: Sat, 5 Sep 2026 13:07:13 +0200 Subject: [PATCH] replace the linux-only firewall patching with a portable exposure model The catalogue publishes 51 compose files with the bare form 'ports: [80]', which binds a random host port on 0.0.0.0: every service answers the internet. ufw-docker existed to take that back afterwards, as root, on linux only, because docker writes its own firewall rules and ufw never sees those ports. Publishing where you mean to solves it at the source. Verified against the daemon: '- 80' gives 0.0.0.0:32768, '127.0.0.1::80' gives 127.0.0.1:32769. Same on macOS and on linux, no privilege, and visible in docker ps. A stack binds with ${MYOS_BIND_PRIVATE|PUBLIC|MESH} and declares what it means with _SERVICE[_]_EXPOSE. myos expose reads the resolved compose configuration and reports what would be opened; --strict fails when a port faces the world without declaring it, which is what an agent runs against a server it did not set up. --- CHANGELOG.md | 5 ++ bin/myos | 5 +- lib/cmd/expose.sh | 82 +++++++++++++++++++++ lib/context.sh | 4 + lib/expose.sh | 80 ++++++++++++++++++++ skills/myos/SKILL.md | 4 + skills/myos/references/commands.md | 2 + skills/myos/references/conventions.md | 46 ++++++++++++ spec/golden/cases.txt | 2 + spec/golden/expected.cli/expose-default.txt | 2 + spec/golden/expected.cli/expose-none.txt | 2 + spec/golden/expected/expose-default.txt | 2 + spec/golden/expected/expose-none.txt | 3 + spec/unit/expose_spec.sh | 79 ++++++++++++++++++++ 14 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 lib/cmd/expose.sh create mode 100644 lib/expose.sh create mode 100644 spec/golden/expected.cli/expose-default.txt create mode 100644 spec/golden/expected.cli/expose-none.txt create mode 100644 spec/golden/expected/expose-default.txt create mode 100644 spec/golden/expected/expose-none.txt create mode 100644 spec/unit/expose_spec.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index befbbe4..eab99ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ - the project `.env` now wins over `/etc/conf.d/myos`, as documented; `MYOS_CONF_PRIORITY=system` restores the previous order - `share/make/shim.mk`: make as an optional front end over the same shell code +- `myos expose` reports what each stack publishes and to whom, and `--strict` + fails when a port faces the world without saying so. `MYOS_BIND_PUBLIC`, + `_PRIVATE` and `_MESH` let a stack bind its published ports, which replaces + the linux-only ufw-docker patching with something that behaves the same on + macOS and needs no privilege - commands chain: `myos build up logs host/fabio`, as make targets did - the stack catalogue no longer needs make at all: its settings are hooks, and only six stacks keep a .mk, for targets diff --git a/bin/myos b/bin/myos index c16a230..17a6e83 100755 --- a/bin/myos +++ b/bin/myos @@ -19,7 +19,7 @@ done MYOS_ROOT=$(cd "$(dirname "$_self")/.." && pwd -P) export MYOS_ROOT -for _m in core str var tags naming stack config compose hooks context; do +for _m in core str var tags naming stack config compose hooks context expose; do # shellcheck source=/dev/null . "$MYOS_ROOT/lib/$_m.sh" done @@ -69,6 +69,7 @@ Commands: env [VAR...] show resolved variables export every setting of the stacks, as KEY=value env-update fill .env from the .env.dist templates + expose [--strict] what the stacks publish, and to whom doctor check the installation version print the myos version @@ -217,7 +218,7 @@ if [ -z "$MYOS_REFS" ]; then # these commands describe the installation rather than act on a stack for _c in $MYOS_CMDS; do case $_c in - env|env-update|export|ls|doctor|version|help) ;; + env|env-update|export|expose|ls|doctor|version|help) ;; *) myos_die "$MYOS_E_USAGE" "no stack given, and no compose file in $WORKDIR" ;; esac done diff --git a/lib/cmd/expose.sh b/lib/cmd/expose.sh new file mode 100644 index 0000000..a0d7b00 --- /dev/null +++ b/lib/cmd/expose.sh @@ -0,0 +1,82 @@ +#shellcheck shell=sh +# myos expose [--strict] what the stacks publish, and to whom +# +# Reads the resolved compose configuration, so it reports what `myos up` would +# open rather than what happens to be running. A port bound to 0.0.0.0 answers +# the internet: on linux docker writes its own firewall rules and the host +# firewall does not see it. --strict exits 1 when a port is world-bound +# without the stack declaring that scope. +myos_cmd_expose() { + _strict=false + case ${MYOS_ARGS:-}${MYOS_VARS:-} in *--strict*) _strict=true ;; esac + + _rows=$(myos_expose_rows) + [ -n "$_rows" ] || { printf 'no published port: nothing is reachable from outside the docker network\n'; return 0; } + + printf '%s%-20s %-14s %-22s %-6s %s%s\n' \ + "$MYOS_C_HIGHLIGHT" STACK SERVICE "PUBLISHED ON" PORT SCOPE "$MYOS_C_RESET" + _bad=0 + _oIFS=$IFS; IFS=' +' + for _row in $_rows; do + IFS=$_oIFS + _st=${_row%%|*}; _rest=${_row#*|} + _sv=${_rest%%|*}; _rest=${_rest#*|} + _on=${_rest%%|*}; _rest=${_rest#*|} + _pt=${_rest%%|*}; _sc=${_rest#*|} + case ${_on%:*} in + 0.0.0.0|''|'::'|'*') + [ "$_sc" = public ] || _bad=$((_bad + 1)) + printf '%-20s %-14s %s%-22s%s %-6s %s\n' \ + "$_st" "$_sv" "$MYOS_C_WARN" "$_on" "$MYOS_C_RESET" "$_pt" "$_sc" ;; + *) + printf '%-20s %-14s %-22s %-6s %s\n' "$_st" "$_sv" "$_on" "$_pt" "$_sc" ;; + esac + IFS=' +' + done + IFS=$_oIFS + + if [ "$_bad" -gt 0 ]; then + myos_warning "$_bad port(s) reachable from anywhere without declaring the public scope" + myos_warning "bind them: ports: [\"\${MYOS_BIND_PRIVATE}::\"]" + [ "$_strict" = true ] && return "$MYOS_E_FAIL" + fi + return 0 +} + +# myos_expose_rows STACK|SERVICE|ADDR:PORT|CONTAINER_PORT|SCOPE for every +# published port of the requested stacks +myos_expose_rows() { + for _ref in $MYOS_STACKS; do + _files=$(myos_stack_compose_files "$_ref" 2>/dev/null) || continue + [ -n "$_files" ] || continue + _fw=$(myos_framework_compose_files) + [ -n "$_fw" ] && _files="$_files +$_fw" + _app=$(myos_stack_name "$_ref") + _project=$(myos_project_name "$(myos_scope "$_ref")" "$USER" "$ENV" "$_app") + DRYRUN=false myos_compose "$_project" "$_files" -- config 2>/dev/null | + myos_expose_parse "$_ref" "$(myos_stack_prefix "$_ref")" + done +} + +# myos_expose_parse STACK NAME (compose config on stdin) +# compose normalises every port to the long form, so one shape is enough +myos_expose_parse() { + awk -v stack="$1" ' + /^services:/ { insvc = 1; next } + insvc && /^ [a-zA-Z0-9_.-]+:/ { svc = $1; sub(/:$/, "", svc); inports = 0 } + insvc && /^ ports:/ { inports = 1; next } + inports && /^ [a-z]/ { inports = 0 } + inports && /host_ip:/ { ip = $2 } + inports && /published:/ { pub = $2; gsub(/"/, "", pub) } + inports && /target:/ { tgt = $2 } + inports && /protocol:/ { + printf "%s|%s|%s:%s|%s\n", stack, svc, (ip == "" ? "0.0.0.0" : ip), pub, tgt + ip = ""; pub = ""; tgt = "" + } + ' | while IFS='|' read -r _s _v _o _t; do + printf '%s|%s|%s|%s|%s\n' "$_s" "$_v" "$_o" "$_t" "$(myos_expose_scope "$2" "$_v" "$_t")" + done +} diff --git a/lib/context.sh b/lib/context.sh index 4fc6f28..df9e017 100644 --- a/lib/context.sh +++ b/lib/context.sh @@ -58,6 +58,10 @@ myos_context_defaults() { myos_default_HOST() { myos_addprefix "${HOSTNAME:-}." "$(myos_var DOMAIN)"; } myos_default_HOSTNAME() { printf '%s' "${HOSTNAME:-}"; } myos_default_DOMAINNAME() { myos_firstword "$(myos_var DOMAIN)"; } + # the addresses a stack binds its published ports to + myos_default_MYOS_BIND_PUBLIC() { myos_bind public; } + myos_default_MYOS_BIND_PRIVATE() { myos_bind private; } + myos_default_MYOS_BIND_MESH() { myos_bind mesh; } myos_default_MACHINE() { uname -m 2>/dev/null; } myos_default_SYSTEM() { uname -s 2>/dev/null; } myos_default_HOST_COMPOSE_PROJECT_NAME() { printf '%s' "${HOSTNAME:-}"; } diff --git a/lib/expose.sh b/lib/expose.sh new file mode 100644 index 0000000..3fac98b --- /dev/null +++ b/lib/expose.sh @@ -0,0 +1,80 @@ +#shellcheck shell=sh +# shellcheck disable=SC3028 # HOSTNAME is a myos variable, set by bin/myos +# expose: which addresses a published port binds to. +# +# Docker writes its own firewall rules, so on linux a port published with +# `-p 8080:80` answers the internet whatever the host firewall says. ufw-docker +# patches that afterwards, on linux only, as root. +# +# The portable answer is to publish where you mean to in the first place: +# `-p 127.0.0.1:8080:80` only ever listens on the loopback, identically on +# linux and on macOS, with no firewall and no privilege. A stack says which +# scope a port belongs to, and myos resolves the address. +# +# public the internet: a load balancer, a public DNS or mail service +# mesh the private network between the hosts of the fleet +# private this host only: everything the load balancer reaches for you +# +# MYOS_BIND_ overrides any of them. + +# myos_bind SCOPE the address a port of that scope binds to +myos_bind() { + case $1 in + public) printf '%s' "${MYOS_BIND_PUBLIC:-0.0.0.0}" ;; + mesh) printf '%s' "${MYOS_BIND_MESH:-$(myos_bind_mesh)}" ;; + private|*) printf '%s' "${MYOS_BIND_PRIVATE:-127.0.0.1}" ;; + esac +} + +# myos_bind_mesh the address of the mesh interface, empty when there is none. +# Falls back to the private address so that a stack scoped to the mesh on a +# host that has none stays local rather than becoming public. +myos_bind_mesh() { + _if=${MYOS_MESH_IFACE:-} + if [ -z "$_if" ]; then + for _c in easytier tun0 tailscale0 mycelium wg0; do + if myos_iface_addr "$_c" >/dev/null 2>&1 && [ -n "$(myos_iface_addr "$_c")" ]; then + _if=$_c; break + fi + done + fi + [ -n "$_if" ] || { printf '%s' "${MYOS_BIND_PRIVATE:-127.0.0.1}"; return 0; } + _a=$(myos_iface_addr "$_if") + [ -n "$_a" ] || _a=${MYOS_BIND_PRIVATE:-127.0.0.1} + printf '%s' "$_a" +} + +# myos_iface_addr NAME the first address of an interface, on linux or macOS +myos_iface_addr() { + if myos_have ip; then + ip -o addr show "$1" 2>/dev/null | awk '$3 ~ /^inet6?$/ {sub(/\/.*/,"",$4); print $4; exit}' + elif myos_have ifconfig; then + ifconfig "$1" 2>/dev/null | awk '$1 == "inet" || $1 == "inet6" {print $2; exit}' + fi +} + +# myos_stack_prefix REF the prefix the settings of a stack use. +# A host stack is prefixed by HOST_, which is how the catalogue names them: +# HOST_FABIO_SERVICE_9998_TAGS, HOST_FTPS_UFW_DOCKER. Everything else uses the +# stack name alone: SUPABASE_KONG_SERVICE_8000_TAGS. +myos_stack_prefix() { + _n=$(myos_upper "$(myos_stack_name "$1")") + case $(myos_scope "$1") in + host) printf 'HOST_%s' "$_n" ;; + user) printf 'USER_%s' "$_n" ;; + *) printf '%s' "$_n" ;; + esac +} + +# myos_expose_scope PREFIX SERVICE PORT the scope a stack declares for a port: +# _SERVICE__EXPOSE, then _SERVICE_EXPOSE, then the same +# two on the service name, else private +myos_expose_scope() { + _u=$(myos_upper "$1") + for _n in "${_u}_SERVICE_${3}_EXPOSE" "${_u}_SERVICE_EXPOSE" \ + "$(myos_upper "$2")_SERVICE_${3}_EXPOSE" "$(myos_upper "$2")_SERVICE_EXPOSE"; do + _s=$(myos_var "$_n") + [ -n "$_s" ] && { printf '%s' "$_s"; return 0; } + done + printf 'private' +} diff --git a/skills/myos/SKILL.md b/skills/myos/SKILL.md index 917c1fc..39bed28 100644 --- a/skills/myos/SKILL.md +++ b/skills/myos/SKILL.md @@ -82,6 +82,10 @@ See `references/conventions.md`. ## Rules +- Check what a stack opens before starting it on a server that faces the + internet: `myos expose `. A port shown on `0.0.0.0` answers the world, + and on linux the host firewall does not see it, because docker writes its own + rules. Bind it instead: `ports: ["${MYOS_BIND_PRIVATE}::"]`. - Never run `myos clean` on a host stack: it removes images **and volumes**, including the certificates. - Secrets belong in a file outside the repository, never in a compose file. diff --git a/skills/myos/references/commands.md b/skills/myos/references/commands.md index c8d9689..901faf4 100644 --- a/skills/myos/references/commands.md +++ b/skills/myos/references/commands.md @@ -27,6 +27,8 @@ myos [options] [stack...] [VAR=value...] [-- args...] | `ls [--groups]` | the stacks and groups myos can see | | `env [VAR...]` | resolved variables | | `env-update` | fill the `.env` from the `.env.dist` templates | +| `expose [--strict]` | what the stacks publish, and to whom | +| `export [--make]` | every setting of the stacks, as `KEY=value` | | `doctor` | check the installation | | `version` | the myos version | diff --git a/skills/myos/references/conventions.md b/skills/myos/references/conventions.md index 0a3701e..5efeebd 100644 --- a/skills/myos/references/conventions.md +++ b/skills/myos/references/conventions.md @@ -168,6 +168,52 @@ A line may refer to a variable defined further down. A variable that already has a value keeps it: the `.env` records choices, it never overwrites them, and running the command twice changes nothing. +## What a stack publishes, and to whom + +On linux docker writes its own firewall rules, so a port published with +`ports: ["8080:80"]` answers the internet whatever the host firewall says. The +portable answer is to publish where you mean to, which behaves the same on +linux and on macOS and needs no privilege: + +```yaml +services: + app: + ports: + - "${MYOS_BIND_PRIVATE}::8080" # this host only, reached through fabio + gateway: + ports: + - "${MYOS_BIND_PUBLIC}:443:443" # the internet, on purpose + peer: + ports: + - "${MYOS_BIND_MESH}::7946" # the private network between the hosts +``` + +| scope | address | for | +|---|---|---| +| `private` | `127.0.0.1` | everything the load balancer reaches for you. The default. | +| `public` | `0.0.0.0` | a load balancer, a public DNS or mail service | +| `mesh` | the mesh interface | services shared between the hosts of a fleet | + +`MYOS_BIND_PUBLIC`, `MYOS_BIND_PRIVATE` and `MYOS_BIND_MESH` override the +addresses; `MYOS_MESH_IFACE` names the interface when it is not one of +easytier, tun0, tailscale0, mycelium or wg0. + +A stack also declares what it means, so an audit can tell a deliberate choice +from an oversight: + +```sh +_SERVICE_EXPOSE=public # the whole stack +_SERVICE_443_EXPOSE=public # one port +``` + +`` is `HOST_` for a host stack, `USER_` for a user stack, +`` otherwise. + +```sh +myos expose # what each stack publishes, and its declared scope +myos expose --strict # exits 1 when a port faces the world undeclared +``` + ## Groups A group is a lowercase name whose value lists stacks. It can live in a `.env`, diff --git a/spec/golden/cases.txt b/spec/golden/cases.txt index 833f94c..a44a36b 100644 --- a/spec/golden/cases.txt +++ b/spec/golden/cases.txt @@ -89,3 +89,5 @@ mk-ssh-with-hosts | app-nogit | ssh SSH_HOSTS=example.test ARG bridge-export | host-project | export STACK=host/consul cmd-recreate | host-project | recreate host/consul cmd-status | host-project | status host/consul +expose-none | host-project | expose host/consul +expose-default | app-git | expose diff --git a/spec/golden/expected.cli/expose-default.txt b/spec/golden/expected.cli/expose-default.txt new file mode 100644 index 0000000..077ed7b --- /dev/null +++ b/spec/golden/expected.cli/expose-default.txt @@ -0,0 +1,2 @@ +no published port: nothing is reachable from outside the docker network +[exit 0] diff --git a/spec/golden/expected.cli/expose-none.txt b/spec/golden/expected.cli/expose-none.txt new file mode 100644 index 0000000..077ed7b --- /dev/null +++ b/spec/golden/expected.cli/expose-none.txt @@ -0,0 +1,2 @@ +no published port: nothing is reachable from outside the docker network +[exit 0] diff --git a/spec/golden/expected/expose-default.txt b/spec/golden/expected/expose-default.txt new file mode 100644 index 0000000..65817d0 --- /dev/null +++ b/spec/golden/expected/expose-default.txt @@ -0,0 +1,2 @@ +WARNING: myos[0] expose-rule-exists: target expose unavailable in app myos +[exit 0] diff --git a/spec/golden/expected/expose-none.txt b/spec/golden/expected/expose-none.txt new file mode 100644 index 0000000..8e2089f --- /dev/null +++ b/spec/golden/expected/expose-none.txt @@ -0,0 +1,3 @@ +WARNING: myos[0] expose-rule-exists: target expose unavailable in app myos +WARNING: myos[0] host/consul-rule-exists: target host/consul unavailable in app myos +[exit 0] diff --git a/spec/unit/expose_spec.sh b/spec/unit/expose_spec.sh new file mode 100644 index 0000000..df28e46 --- /dev/null +++ b/spec/unit/expose_spec.sh @@ -0,0 +1,79 @@ +#shellcheck shell=sh +Include lib/core.sh +Include lib/str.sh +Include lib/var.sh +Include lib/tags.sh +Include lib/naming.sh +Include lib/stack.sh +Include lib/expose.sh + +# Docker writes its own firewall rules, so a port published to 0.0.0.0 answers +# the internet whatever the host firewall says. Binding the publication is the +# portable answer: it behaves the same on linux and on macOS, without root. +Describe 'lib/expose.sh' + Describe 'myos_bind' + It 'binds the private scope to the loopback' + When call myos_bind private + The output should equal "127.0.0.1" + End + It 'binds the public scope to every address' + When call myos_bind public + The output should equal "0.0.0.0" + End + It 'takes an explicit address over the default' + MYOS_BIND_PRIVATE=10.0.0.1 + When call myos_bind private + The output should equal "10.0.0.1" + End + It 'keeps an unknown scope private rather than public' + When call myos_bind nonsense + The output should equal "127.0.0.1" + End + It 'falls back to the private address when there is no mesh' + MYOS_MESH_IFACE=nosuchiface0 + When call myos_bind mesh + The output should equal "127.0.0.1" + End + It 'uses the mesh address when one is given' + MYOS_BIND_MESH=10.144.0.2 + When call myos_bind mesh + The output should equal "10.144.0.2" + End + End + + Describe 'myos_stack_prefix' + Parameters + "host/fabio" "HOST_FABIO" + "User/ipfs" "USER_IPFS" + "supabase" "SUPABASE" + "drone/drone" "DRONE" + End + It "prefixes the settings of $1 with $2" + When call myos_stack_prefix "$1" + The output should equal "$2" + End + End + + Describe 'myos_expose_scope' + It 'is private unless the stack says otherwise' + When call myos_expose_scope HOST_FTPS ftps 21 + The output should equal "private" + End + It 'reads the scope of one port' + HOST_FTPS_SERVICE_21_EXPOSE=public + When call myos_expose_scope HOST_FTPS ftps 21 + The output should equal "public" + End + It 'reads the scope of a whole stack' + HOST_FTPS_SERVICE_EXPOSE=mesh + When call myos_expose_scope HOST_FTPS ftps 21 + The output should equal "mesh" + End + It 'prefers the port over the stack' + HOST_FTPS_SERVICE_EXPOSE=mesh + HOST_FTPS_SERVICE_21_EXPOSE=public + When call myos_expose_scope HOST_FTPS ftps 21 + The output should equal "public" + End + End +End