read the exposure scope from the compose file, drop the _EXPOSE variable
The variable declared an intent and changed nothing: it fed the report and
never the binding, so it could say public while the file published on the
loopback, or the reverse. A declaration that cannot be wrong is better than one
that has to be kept in step.
The scope is now which binding the file asks for, read from the compose files
as written: ${MYOS_BIND_PUBLIC} is public, a hand-written address is pinned,
and a bare '- 80' or '9000:9000' is unbound, meaning docker opens it on every
address and nobody chose that. --strict fails on those.
Reading the resolved configuration instead would lose the distinction: every
form ends up as a plain address, and an unbound port looks exactly like a
deliberate public one.
What is left to configure per host is the address of each scope, which is the
part that belongs to the host rather than to the stack.
This commit is contained in:
+2
-1
@@ -24,7 +24,8 @@
|
|||||||
fails when a port faces the world without saying so. `MYOS_BIND_PUBLIC`,
|
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
|
`_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
|
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
|
- 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
|
||||||
|
|||||||
+45
-31
@@ -1,52 +1,56 @@
|
|||||||
#shellcheck shell=sh
|
#shellcheck shell=sh
|
||||||
# myos expose [--strict] what the stacks publish, and to whom
|
# myos expose [--strict] what the stacks publish, and to whom
|
||||||
#
|
#
|
||||||
# Reads the resolved compose configuration, so it reports what `myos up` would
|
# Two readings are joined: the compose files as written, which say which
|
||||||
# open rather than what happens to be running. A port bound to 0.0.0.0 answers
|
# binding each port asks for, and the resolved configuration, which says the
|
||||||
# the internet: on linux docker writes its own firewall rules and the host
|
# address it ends up on. The first is the intent, the second is the fact, and
|
||||||
# firewall does not see it. --strict exits 1 when a port is world-bound
|
# reporting both is the point: a port nobody bound answers the internet, and on
|
||||||
# without the stack declaring that scope.
|
# 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() {
|
myos_cmd_expose() {
|
||||||
_strict=false
|
_strict=false
|
||||||
case ${MYOS_ARGS:-}${MYOS_VARS:-} in *--strict*) _strict=true ;; esac
|
case ${MYOS_ARGS:-}${MYOS_VARS:-} in *--strict*) _strict=true ;; esac
|
||||||
|
|
||||||
_rows=$(myos_expose_rows)
|
_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' \
|
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
|
_bad=0
|
||||||
_oIFS=$IFS; IFS='
|
_oIFS=$IFS; IFS='
|
||||||
'
|
'
|
||||||
for _row in $_rows; do
|
for _row in $_rows; do
|
||||||
IFS=$_oIFS
|
IFS=$_oIFS
|
||||||
_st=${_row%%|*}; _rest=${_row#*|}
|
_st=${_row%%|*}; _r=${_row#*|}
|
||||||
_sv=${_rest%%|*}; _rest=${_rest#*|}
|
_sv=${_r%%|*}; _r=${_r#*|}
|
||||||
_on=${_rest%%|*}; _rest=${_rest#*|}
|
_on=${_r%%|*}; _r=${_r#*|}
|
||||||
_pt=${_rest%%|*}; _sc=${_rest#*|}
|
_pt=${_r%%|*}; _sc=${_r#*|}
|
||||||
case ${_on%:*} in
|
if [ "$_sc" = unbound ]; then
|
||||||
0.0.0.0|''|'::'|'*')
|
_bad=$((_bad + 1))
|
||||||
[ "$_sc" = public ] || _bad=$((_bad + 1))
|
printf '%-20s %-14s %s%-22s%s %-6s %s%s%s\n' "$_st" "$_sv" \
|
||||||
printf '%-20s %-14s %s%-22s%s %-6s %s\n' \
|
"$MYOS_C_WARN" "$_on" "$MYOS_C_RESET" "$_pt" "$MYOS_C_WARN" "$_sc" "$MYOS_C_RESET"
|
||||||
"$_st" "$_sv" "$MYOS_C_WARN" "$_on" "$MYOS_C_RESET" "$_pt" "$_sc" ;;
|
else
|
||||||
*)
|
printf '%-20s %-14s %-22s %-6s %s\n' "$_st" "$_sv" "$_on" "$_pt" "$_sc"
|
||||||
printf '%-20s %-14s %-22s %-6s %s\n' "$_st" "$_sv" "$_on" "$_pt" "$_sc" ;;
|
fi
|
||||||
esac
|
|
||||||
IFS='
|
IFS='
|
||||||
'
|
'
|
||||||
done
|
done
|
||||||
IFS=$_oIFS
|
IFS=$_oIFS
|
||||||
|
|
||||||
if [ "$_bad" -gt 0 ]; then
|
if [ "$_bad" -gt 0 ]; then
|
||||||
myos_warning "$_bad port(s) reachable from anywhere without declaring the public scope"
|
myos_warning "$_bad port(s) published without a binding: docker opens them on every address"
|
||||||
myos_warning "bind them: ports: [\"\${MYOS_BIND_PRIVATE}::<port>\"]"
|
# shellcheck disable=SC2016 # the variable name is the message, not a value
|
||||||
|
myos_warning 'bind them: ports: ["${MYOS_BIND_PRIVATE}::<port>"] for a service behind the load balancer'
|
||||||
[ "$_strict" = true ] && return "$MYOS_E_FAIL"
|
[ "$_strict" = true ] && return "$MYOS_E_FAIL"
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# myos_expose_rows STACK|SERVICE|ADDR:PORT|CONTAINER_PORT|SCOPE for every
|
# myos_expose_rows STACK|SERVICE|ADDR:PORT|CONTAINER_PORT|BINDING
|
||||||
# published port of the requested stacks
|
|
||||||
myos_expose_rows() {
|
myos_expose_rows() {
|
||||||
for _ref in $MYOS_STACKS; do
|
for _ref in $MYOS_STACKS; do
|
||||||
_files=$(myos_stack_compose_files "$_ref" 2>/dev/null) || continue
|
_files=$(myos_stack_compose_files "$_ref" 2>/dev/null) || continue
|
||||||
@@ -56,15 +60,26 @@ myos_expose_rows() {
|
|||||||
$_fw"
|
$_fw"
|
||||||
_app=$(myos_stack_name "$_ref")
|
_app=$(myos_stack_name "$_ref")
|
||||||
_project=$(myos_project_name "$(myos_scope "$_ref")" "$USER" "$ENV" "$_app")
|
_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 |
|
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
|
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
|
# compose normalises every port to the long form, so one shape is enough
|
||||||
myos_expose_parse() {
|
myos_expose_resolved() {
|
||||||
awk -v stack="$1" '
|
awk '
|
||||||
/^services:/ { insvc = 1; next }
|
/^services:/ { insvc = 1; next }
|
||||||
insvc && /^ [a-zA-Z0-9_.-]+:/ { svc = $1; sub(/:$/, "", svc); inports = 0 }
|
insvc && /^ [a-zA-Z0-9_.-]+:/ { svc = $1; sub(/:$/, "", svc); inports = 0 }
|
||||||
insvc && /^ ports:/ { inports = 1; next }
|
insvc && /^ ports:/ { inports = 1; next }
|
||||||
@@ -73,10 +88,9 @@ myos_expose_parse() {
|
|||||||
inports && /published:/ { pub = $2; gsub(/"/, "", pub) }
|
inports && /published:/ { pub = $2; gsub(/"/, "", pub) }
|
||||||
inports && /target:/ { tgt = $2 }
|
inports && /target:/ { tgt = $2 }
|
||||||
inports && /protocol:/ {
|
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 = ""
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-12
@@ -15,7 +15,11 @@
|
|||||||
# mesh the private network between the hosts of the fleet
|
# mesh the private network between the hosts of the fleet
|
||||||
# private this host only: everything the load balancer reaches for you
|
# private this host only: everything the load balancer reaches for you
|
||||||
#
|
#
|
||||||
# MYOS_BIND_<SCOPE> 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_<SCOPE> 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 SCOPE the address a port of that scope binds to
|
||||||
myos_bind() {
|
myos_bind() {
|
||||||
@@ -66,15 +70,43 @@ myos_stack_prefix() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# myos_expose_scope PREFIX SERVICE PORT the scope a stack declares for a port:
|
# myos_expose_declared FILE... SERVICE|CONTAINER_PORT|SCOPE for every port a
|
||||||
# <PREFIX>_SERVICE_<port>_EXPOSE, then <PREFIX>_SERVICE_EXPOSE, then the same
|
# compose file publishes, read from the file as written rather than from the
|
||||||
# two on the service name, else private
|
# resolved configuration.
|
||||||
myos_expose_scope() {
|
#
|
||||||
_u=$(myos_upper "$1")
|
# The scope is not declared twice: it is which binding the file asks for.
|
||||||
for _n in "${_u}_SERVICE_${3}_EXPOSE" "${_u}_SERVICE_EXPOSE" \
|
# ${MYOS_BIND_PUBLIC}:443:443 public
|
||||||
"$(myos_upper "$2")_SERVICE_${3}_EXPOSE" "$(myos_upper "$2")_SERVICE_EXPOSE"; do
|
# ${MYOS_BIND_PRIVATE}::8080 private
|
||||||
_s=$(myos_var "$_n")
|
# ${MYOS_BIND_MESH}::7946 mesh
|
||||||
[ -n "$_s" ] && { printf '%s' "$_s"; return 0; }
|
# 127.0.0.1:5432:5432 pinned to an address, deliberate but fixed
|
||||||
done
|
# 80 or 8080:80 unbound: docker binds every address, and
|
||||||
printf 'private'
|
# 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) }
|
||||||
|
' "$@"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ See `references/conventions.md`.
|
|||||||
- Check what a stack opens before starting it on a server that faces the
|
- 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,
|
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
|
and on linux the host firewall does not see it, because docker writes its own
|
||||||
rules. Bind it instead: `ports: ["${MYOS_BIND_PRIVATE}::<port>"]`.
|
rules. A port reported as `unbound` was published without anyone choosing an
|
||||||
|
address: bind it with `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.
|
||||||
|
|||||||
@@ -198,21 +198,23 @@ services:
|
|||||||
addresses; `MYOS_MESH_IFACE` names the interface when it is not one of
|
addresses; `MYOS_MESH_IFACE` names the interface when it is not one of
|
||||||
easytier, tun0, tailscale0, mycelium or wg0.
|
easytier, tun0, tailscale0, mycelium or wg0.
|
||||||
|
|
||||||
A stack also declares what it means, so an audit can tell a deliberate choice
|
There is nothing else to declare: the scope **is** the binding the file asks
|
||||||
from an oversight:
|
for. A port written `- 80` or `- "9000:9000"` is *unbound*, which means docker
|
||||||
|
opens it on every address and nobody chose that.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
<PREFIX>_SERVICE_EXPOSE=public # the whole stack
|
myos expose # what each stack publishes, on which address
|
||||||
<PREFIX>_SERVICE_443_EXPOSE=public # one port
|
myos expose --strict # exits 1 when a port is published without a binding
|
||||||
```
|
```
|
||||||
|
|
||||||
`<PREFIX>` is `HOST_<name>` for a host stack, `USER_<name>` for a user stack,
|
The command reads the compose files as written **and** the resolved
|
||||||
`<name>` otherwise.
|
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
|
The split of responsibility: the **scope** belongs to the stack, in its compose
|
||||||
myos expose # what each stack publishes, and its declared scope
|
file; the **address** of a scope belongs to the host, in its configuration.
|
||||||
myos expose --strict # exits 1 when a port faces the world undeclared
|
|
||||||
```
|
|
||||||
|
|
||||||
## Groups
|
## Groups
|
||||||
|
|
||||||
|
|||||||
+38
-17
@@ -54,26 +54,47 @@ Describe 'lib/expose.sh'
|
|||||||
End
|
End
|
||||||
End
|
End
|
||||||
|
|
||||||
Describe 'myos_expose_scope'
|
Describe 'myos_expose_declared'
|
||||||
It 'is private unless the stack says otherwise'
|
setup() { MYOS_TMP=$(mktemp -d "${TMPDIR:-/tmp}/myos-exp.XXXXXX"); }
|
||||||
When call myos_expose_scope HOST_FTPS ftps 21
|
cleanup() { rm -rf "$MYOS_TMP"; }
|
||||||
The output should equal "private"
|
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
|
End
|
||||||
It 'reads the scope of one port'
|
|
||||||
HOST_FTPS_SERVICE_21_EXPOSE=public
|
It 'calls a plain host:container mapping unbound, because it is'
|
||||||
When call myos_expose_scope HOST_FTPS ftps 21
|
printf 'services:\n a:\n ports:\n - "9000:9000"\n - 25:25\n' > "$MYOS_TMP/c.yml"
|
||||||
The output should equal "public"
|
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
|
End
|
||||||
It 'reads the scope of a whole stack'
|
|
||||||
HOST_FTPS_SERVICE_EXPOSE=mesh
|
It 'keeps the protocol out of the port'
|
||||||
When call myos_expose_scope HOST_FTPS ftps 21
|
printf 'services:\n a:\n ports:\n - 4001/udp\n' > "$MYOS_TMP/c.yml"
|
||||||
The output should equal "mesh"
|
When call myos_expose_declared "$MYOS_TMP/c.yml"
|
||||||
|
The output should equal "a|4001|unbound"
|
||||||
End
|
End
|
||||||
It 'prefers the port over the stack'
|
|
||||||
HOST_FTPS_SERVICE_EXPOSE=mesh
|
It 'reports nothing for a service that publishes nothing'
|
||||||
HOST_FTPS_SERVICE_21_EXPOSE=public
|
printf 'services:\n a:\n image: alpine\n' > "$MYOS_TMP/c.yml"
|
||||||
When call myos_expose_scope HOST_FTPS ftps 21
|
When call myos_expose_declared "$MYOS_TMP/c.yml"
|
||||||
The output should equal "public"
|
The output should equal ""
|
||||||
End
|
End
|
||||||
End
|
End
|
||||||
End
|
End
|
||||||
|
|||||||
Reference in New Issue
Block a user