diff --git a/CHANGELOG.md b/CHANGELOG.md index eab99ea..d78e1ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ 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 + macOS and needs no privilege. The scope is read from the compose file rather + than declared beside it, so it cannot disagree with what is published - 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/lib/cmd/expose.sh b/lib/cmd/expose.sh index a0d7b00..4700fa6 100644 --- a/lib/cmd/expose.sh +++ b/lib/cmd/expose.sh @@ -1,52 +1,56 @@ #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. +# Two readings are joined: the compose files as written, which say which +# binding each port asks for, and the resolved configuration, which says the +# address it ends up on. The first is the intent, the second is the fact, and +# reporting both is the point: a port nobody bound answers the internet, and on +# linux the host firewall does not see it, because docker writes its own rules. +# +# --strict exits 1 when a port is published without a binding. 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; } + [ -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" + "$MYOS_C_HIGHLIGHT" STACK SERVICE "PUBLISHED ON" PORT BINDING "$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 + _st=${_row%%|*}; _r=${_row#*|} + _sv=${_r%%|*}; _r=${_r#*|} + _on=${_r%%|*}; _r=${_r#*|} + _pt=${_r%%|*}; _sc=${_r#*|} + if [ "$_sc" = unbound ]; then + _bad=$((_bad + 1)) + printf '%-20s %-14s %s%-22s%s %-6s %s%s%s\n' "$_st" "$_sv" \ + "$MYOS_C_WARN" "$_on" "$MYOS_C_RESET" "$_pt" "$MYOS_C_WARN" "$_sc" "$MYOS_C_RESET" + else + printf '%-20s %-14s %-22s %-6s %s\n' "$_st" "$_sv" "$_on" "$_pt" "$_sc" + fi 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}::\"]" + myos_warning "$_bad port(s) published without a binding: docker opens them on every address" + # shellcheck disable=SC2016 # the variable name is the message, not a value + myos_warning 'bind them: ports: ["${MYOS_BIND_PRIVATE}::"] for a service behind the load balancer' [ "$_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 STACK|SERVICE|ADDR:PORT|CONTAINER_PORT|BINDING myos_expose_rows() { for _ref in $MYOS_STACKS; do _files=$(myos_stack_compose_files "$_ref" 2>/dev/null) || continue @@ -56,15 +60,26 @@ myos_expose_rows() { $_fw" _app=$(myos_stack_name "$_ref") _project=$(myos_project_name "$(myos_scope "$_ref")" "$USER" "$ENV" "$_app") + + # what the files ask for, later overlays overriding earlier ones + _decl=$(mktemp "${TMPDIR:-/tmp}/myos-expose.XXXXXX") + # shellcheck disable=SC2086 # a newline separated list of paths + myos_expose_declared $_files > "$_decl" 2>/dev/null + DRYRUN=false myos_compose "$_project" "$_files" -- config 2>/dev/null | - myos_expose_parse "$_ref" "$(myos_stack_prefix "$_ref")" + myos_expose_resolved | + while IFS='|' read -r _v _t _o; do + _b=$(awk -F'|' -v s="$_v" -v p="$_t" '$1==s && $2==p {last=$3} END {print last}' "$_decl") + printf '%s|%s|%s|%s|%s\n' "$_ref" "$_v" "$_o" "$_t" "${_b:-unbound}" + done + rm -f "$_decl" done } -# myos_expose_parse STACK NAME (compose config on stdin) +# myos_expose_resolved (compose config on stdin) -> SERVICE|CONTAINER_PORT|ADDR:PORT # compose normalises every port to the long form, so one shape is enough -myos_expose_parse() { - awk -v stack="$1" ' +myos_expose_resolved() { + awk ' /^services:/ { insvc = 1; next } insvc && /^ [a-zA-Z0-9_.-]+:/ { svc = $1; sub(/:$/, "", svc); inports = 0 } insvc && /^ ports:/ { inports = 1; next } @@ -73,10 +88,9 @@ myos_expose_parse() { 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 + # compose leaves published empty when docker picks the port at run time + printf "%s|%s|%s:%s\n", svc, tgt, (ip == "" ? "0.0.0.0" : ip), (pub == "" ? "auto" : pub) 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/expose.sh b/lib/expose.sh index 3fac98b..6d97205 100644 --- a/lib/expose.sh +++ b/lib/expose.sh @@ -15,7 +15,11 @@ # 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. +# A stack does not declare its scope on the side: it is which of these it binds +# to, read from the compose file. One source of truth, which cannot drift from +# what is actually published. MYOS_BIND_ sets the address of a scope on +# a given host, which is the part that belongs to the host rather than to the +# stack. # myos_bind SCOPE the address a port of that scope binds to myos_bind() { @@ -66,15 +70,43 @@ myos_stack_prefix() { 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' +# myos_expose_declared FILE... SERVICE|CONTAINER_PORT|SCOPE for every port a +# compose file publishes, read from the file as written rather than from the +# resolved configuration. +# +# The scope is not declared twice: it is which binding the file asks for. +# ${MYOS_BIND_PUBLIC}:443:443 public +# ${MYOS_BIND_PRIVATE}::8080 private +# ${MYOS_BIND_MESH}::7946 mesh +# 127.0.0.1:5432:5432 pinned to an address, deliberate but fixed +# 80 or 8080:80 unbound: docker binds every address, and +# nobody chose that +# +# Resolving first would lose the difference: ${MYOS_BIND_PRIVATE} and a +# hand-written 127.0.0.1 both become 127.0.0.1, and an unbound port becomes +# 0.0.0.0 exactly like a deliberate public one. +myos_expose_declared() { + awk ' + function emit(entry, e, scope, target) { + e = entry + gsub(/^[ \t"'"'"'-]+/, "", e); gsub(/["'"'"']+$/, "", e) + if (e ~ /\$\{MYOS_BIND_PUBLIC[^}]*\}/) scope = "public" + else if (e ~ /\$\{MYOS_BIND_MESH[^}]*\}/) scope = "mesh" + else if (e ~ /\$\{MYOS_BIND_PRIVATE[^}]*\}/) scope = "private" + else if (e ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:/) scope = "pinned" + else if (e ~ /^\[/) scope = "pinned" + else scope = "unbound" + # the container port is the last field, minus any /protocol + target = e + sub(/\/[a-z]+$/, "", target) + n = split(target, parts, ":") + target = parts[n] + if (target ~ /^[0-9]+(-[0-9]+)?$/) printf "%s|%s|%s\n", svc, target, scope + } + /^services:[ \t]*$/ { insvc = 1; next } + insvc && /^ [a-zA-Z0-9_.-]+:[ \t]*$/ { svc = $1; sub(/:$/, "", svc); inports = 0 } + insvc && /^ ports:/ { inports = 1; next } + inports && /^ [a-zA-Z]/ { inports = 0 } + inports && /^ *-/ { emit($0) } + ' "$@" } diff --git a/skills/myos/SKILL.md b/skills/myos/SKILL.md index 39bed28..f80404f 100644 --- a/skills/myos/SKILL.md +++ b/skills/myos/SKILL.md @@ -85,7 +85,8 @@ See `references/conventions.md`. - 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}::"]`. + rules. A port reported as `unbound` was published without anyone choosing an + address: bind it with `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/conventions.md b/skills/myos/references/conventions.md index 5efeebd..dc5300d 100644 --- a/skills/myos/references/conventions.md +++ b/skills/myos/references/conventions.md @@ -198,21 +198,23 @@ services: 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: +There is nothing else to declare: the scope **is** the binding the file asks +for. A port written `- 80` or `- "9000:9000"` is *unbound*, which means docker +opens it on every address and nobody chose that. ```sh -_SERVICE_EXPOSE=public # the whole stack -_SERVICE_443_EXPOSE=public # one port +myos expose # what each stack publishes, on which address +myos expose --strict # exits 1 when a port is published without a binding ``` -`` is `HOST_` for a host stack, `USER_` for a user stack, -`` otherwise. +The command reads the compose files as written **and** the resolved +configuration, and shows both: the binding the stack asked for, and the address +it ends up on. Resolving first would lose the difference, since +`${MYOS_BIND_PRIVATE}` and a hand-written `127.0.0.1` both become `127.0.0.1`, +and an unbound port becomes `0.0.0.0` exactly like a deliberate public one. -```sh -myos expose # what each stack publishes, and its declared scope -myos expose --strict # exits 1 when a port faces the world undeclared -``` +The split of responsibility: the **scope** belongs to the stack, in its compose +file; the **address** of a scope belongs to the host, in its configuration. ## Groups diff --git a/spec/unit/expose_spec.sh b/spec/unit/expose_spec.sh index df28e46..07f1d7d 100644 --- a/spec/unit/expose_spec.sh +++ b/spec/unit/expose_spec.sh @@ -54,26 +54,47 @@ Describe 'lib/expose.sh' 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" + Describe 'myos_expose_declared' + setup() { MYOS_TMP=$(mktemp -d "${TMPDIR:-/tmp}/myos-exp.XXXXXX"); } + cleanup() { rm -rf "$MYOS_TMP"; } + BeforeEach setup + AfterEach cleanup + + # The scope is not declared on the side: it is which binding the compose + # file asks for. Reading the resolved configuration instead would lose the + # difference, since every form ends up as a plain address. + It 'reads the scope out of the binding each port asks for' + printf 'services:\n a:\n ports:\n' > "$MYOS_TMP/c.yml" + printf ' - "${MYOS_BIND_PUBLIC}:443:443"\n' >> "$MYOS_TMP/c.yml" + printf ' - "${MYOS_BIND_PRIVATE}::8080"\n' >> "$MYOS_TMP/c.yml" + printf ' - "${MYOS_BIND_MESH}::7946"\n' >> "$MYOS_TMP/c.yml" + printf ' - "127.0.0.1:5432:5432"\n' >> "$MYOS_TMP/c.yml" + printf ' - 80\n' >> "$MYOS_TMP/c.yml" + When call myos_expose_declared "$MYOS_TMP/c.yml" + The line 1 should equal "a|443|public" + The line 2 should equal "a|8080|private" + The line 3 should equal "a|7946|mesh" + The line 4 should equal "a|5432|pinned" + The line 5 should equal "a|80|unbound" 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" + + It 'calls a plain host:container mapping unbound, because it is' + printf 'services:\n a:\n ports:\n - "9000:9000"\n - 25:25\n' > "$MYOS_TMP/c.yml" + When call myos_expose_declared "$MYOS_TMP/c.yml" + The line 1 should equal "a|9000|unbound" + The line 2 should equal "a|25|unbound" 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" + + It 'keeps the protocol out of the port' + printf 'services:\n a:\n ports:\n - 4001/udp\n' > "$MYOS_TMP/c.yml" + When call myos_expose_declared "$MYOS_TMP/c.yml" + The output should equal "a|4001|unbound" 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" + + It 'reports nothing for a service that publishes nothing' + printf 'services:\n a:\n image: alpine\n' > "$MYOS_TMP/c.yml" + When call myos_expose_declared "$MYOS_TMP/c.yml" + The output should equal "" End End End