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 <PREFIX>_SERVICE[_<port>]_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.
This commit is contained in:
Yann Autissier
2026-09-05 13:07:13 +02:00
parent 60668fc80a
commit 90bb97cca8
14 changed files with 316 additions and 2 deletions
+5
View File
@@ -20,6 +20,11 @@
- the project `.env` now wins over `/etc/conf.d/myos`, as documented; - the project `.env` now wins over `/etc/conf.d/myos`, as documented;
`MYOS_CONF_PRIORITY=system` restores the previous order `MYOS_CONF_PRIORITY=system` restores the previous order
- `share/make/shim.mk`: make as an optional front end over the same shell code - `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 - 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 - the stack catalogue no longer needs make at all: its settings are hooks, and
only six stacks keep a .mk, for targets only six stacks keep a .mk, for targets
+3 -2
View File
@@ -19,7 +19,7 @@ done
MYOS_ROOT=$(cd "$(dirname "$_self")/.." && pwd -P) MYOS_ROOT=$(cd "$(dirname "$_self")/.." && pwd -P)
export MYOS_ROOT 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 # shellcheck source=/dev/null
. "$MYOS_ROOT/lib/$_m.sh" . "$MYOS_ROOT/lib/$_m.sh"
done done
@@ -69,6 +69,7 @@ Commands:
env [VAR...] show resolved variables env [VAR...] show resolved variables
export every setting of the stacks, as KEY=value export every setting of the stacks, as KEY=value
env-update fill .env from the .env.dist templates env-update fill .env from the .env.dist templates
expose [--strict] what the stacks publish, and to whom
doctor check the installation doctor check the installation
version print the myos version 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 # these commands describe the installation rather than act on a stack
for _c in $MYOS_CMDS; do for _c in $MYOS_CMDS; do
case $_c in 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" ;; *) myos_die "$MYOS_E_USAGE" "no stack given, and no compose file in $WORKDIR" ;;
esac esac
done done
+82
View File
@@ -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}::<port>\"]"
[ "$_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
}
+4
View File
@@ -58,6 +58,10 @@ myos_context_defaults() {
myos_default_HOST() { myos_addprefix "${HOSTNAME:-}." "$(myos_var DOMAIN)"; } myos_default_HOST() { myos_addprefix "${HOSTNAME:-}." "$(myos_var DOMAIN)"; }
myos_default_HOSTNAME() { printf '%s' "${HOSTNAME:-}"; } myos_default_HOSTNAME() { printf '%s' "${HOSTNAME:-}"; }
myos_default_DOMAINNAME() { myos_firstword "$(myos_var DOMAIN)"; } 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_MACHINE() { uname -m 2>/dev/null; }
myos_default_SYSTEM() { uname -s 2>/dev/null; } myos_default_SYSTEM() { uname -s 2>/dev/null; }
myos_default_HOST_COMPOSE_PROJECT_NAME() { printf '%s' "${HOSTNAME:-}"; } myos_default_HOST_COMPOSE_PROJECT_NAME() { printf '%s' "${HOSTNAME:-}"; }
+80
View File
@@ -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_<SCOPE> 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:
# <PREFIX>_SERVICE_<port>_EXPOSE, then <PREFIX>_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'
}
+4
View File
@@ -82,6 +82,10 @@ See `references/conventions.md`.
## Rules ## Rules
- Check what a stack opens before starting it on a server that faces the
internet: `myos expose <stack>`. 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}::<port>"]`.
- Never run `myos clean` on a host stack: it removes images **and volumes**, - Never run `myos clean` on a host stack: it removes images **and volumes**,
including the certificates. including the certificates.
- Secrets belong in a file outside the repository, never in a compose file. - Secrets belong in a file outside the repository, never in a compose file.
+2
View File
@@ -27,6 +27,8 @@ myos [options] <command> [stack...] [VAR=value...] [-- args...]
| `ls [--groups]` | the stacks and groups myos can see | | `ls [--groups]` | the stacks and groups myos can see |
| `env [VAR...]` | resolved variables | | `env [VAR...]` | resolved variables |
| `env-update` | fill the `.env` from the `.env.dist` templates | | `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 | | `doctor` | check the installation |
| `version` | the myos version | | `version` | the myos version |
+46
View File
@@ -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 has a value keeps it: the `.env` records choices, it never overwrites them, and
running the command twice changes nothing. 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
<PREFIX>_SERVICE_EXPOSE=public # the whole stack
<PREFIX>_SERVICE_443_EXPOSE=public # one port
```
`<PREFIX>` is `HOST_<name>` for a host stack, `USER_<name>` for a user stack,
`<name>` 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 ## Groups
A group is a lowercase name whose value lists stacks. It can live in a `.env`, A group is a lowercase name whose value lists stacks. It can live in a `.env`,
+2
View File
@@ -89,3 +89,5 @@ mk-ssh-with-hosts | app-nogit | ssh SSH_HOSTS=example.test ARG
bridge-export | host-project | export STACK=host/consul bridge-export | host-project | export STACK=host/consul
cmd-recreate | host-project | recreate host/consul cmd-recreate | host-project | recreate host/consul
cmd-status | host-project | status host/consul cmd-status | host-project | status host/consul
expose-none | host-project | expose host/consul
expose-default | app-git | expose
@@ -0,0 +1,2 @@
no published port: nothing is reachable from outside the docker network
[exit 0]
+2
View File
@@ -0,0 +1,2 @@
no published port: nothing is reachable from outside the docker network
[exit 0]
+2
View File
@@ -0,0 +1,2 @@
WARNING: myos[0] expose-rule-exists: target expose unavailable in app myos
[exit 0]
+3
View File
@@ -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]
+79
View File
@@ -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