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