Compare commits

...
4 Commits
Author SHA1 Message Date
Yann Autissier 90bb97cca8 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.
2026-09-05 13:07:13 +02:00
Yann Autissier 60668fc80a implement recreate, reload and status, and stop advertising -H
The usage listed recreate but the dispatcher did not know it: myos recreate
exited 2 saying the command was unknown. status was missing too, and -H was
parsed into a variable nothing ever reads, so it was accepted and ignored. It
now refuses rather than pretending.
2026-09-05 12:15:05 +02:00
Yann Autissier f2bcbc6857 give make a single call for the settings the CLI resolves
myos export prints every setting the requested stacks declare, in one process:
68 variables in half a second, where asking for them one at a time costs a
process each and about ten seconds.

The shim reads that once and includes it, so a .mk target sees
HOST_FABIO_VERSION and the computed fabio tag without make knowing anything
about how they are produced. --make quotes the values so make neither expands
a $ nor starts a comment at a #.
2026-09-05 12:10:32 +02:00
Yann Autissier ca7338bcb6 fix the bugs the rewrite uncovered, in the make engine too
- the wrapper followed one absolute symlink only, and fed the config file to
  env(1) as-is, so a comment or a blank line in /etc/conf.d/myos made every
  command fail with 'env: #comment: No such file or directory'
- verlt called verlte, which does not exist, and used return outside a
  function: it never compared anything
- stat asked for the access time on macOS and the modification time elsewhere,
  so newer/older did not mean the same thing depending on the machine
- setup-docker-group called ansible-user-add-groups, removed with ansible, then
  announced that the user had been added to the docker group. It now runs
  usermod or addgroup, and says so when neither works
- the ssh targets looped over AWS_INSTANCE_IP, which nothing defines since
  make/apps/aws was dropped: they exited 0 having done nothing. They take
  SSH_HOSTS and fail when it is empty
- patsublist left a space before the comma joining two fabio routes
- the JWT macro splits a payload on its commas; documented at the macro, since
  fixing it would change every key it has already produced
2026-09-05 12:03:53 +02:00
34 changed files with 479 additions and 29 deletions
+5
View File
@@ -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
+9 -7
View File
@@ -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
@@ -28,7 +28,7 @@ done
myos_is_command() {
case $1 in
up|down|start|stop|restart|ps|logs|config|build|pull|create|kill|top|images) return 0 ;;
version|help) return 0 ;;
version|help|export) return 0 ;;
print-*|stack-*-*) return 0 ;;
*@*) myos_is_command "${1%@*}"; return $? ;;
esac
@@ -44,7 +44,6 @@ myos_is_command() {
MYOS_REFS_RAW=
MYOS_VARS=
MYOS_ARGS=
MYOS_HOSTS=
MYOS_COLOR=${MYOS_COLOR:-auto}
VERBOSE=${VERBOSE:-}
DEBUG=${DEBUG:-}
@@ -57,7 +56,6 @@ Usage: myos [options] <command> [stack...] [VAR=value...] [-- args...]
Options:
-C DIR work in DIR instead of the current directory
-e ENV environment (default: local, or ENV from the config)
-H HOSTS run on remote hosts instead (comma separated, or "all")
-n, --dry-run print the commands instead of running them
--color WHEN always, never or auto (default: colour when on a terminal)
-v, --verbose show what myos does
@@ -66,10 +64,12 @@ Options:
Commands:
up down start stop restart recreate manage the containers of a stack
ps logs config exec run inspect and enter them
ps status logs config exec run inspect and enter them
ls [--groups] list the stacks myos can see
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
@@ -85,7 +85,9 @@ while [ $# -gt 0 ]; do
case $1 in
-C) WORKDIR=$2; shift 2 ;;
-e) ENV=$2; shift 2 ;;
-H) MYOS_HOSTS=$2; shift 2 ;;
# running on remote hosts is not implemented yet; the flag is refused
# rather than silently ignored
-H) myos_die "$MYOS_E_USAGE" "-H is not implemented yet: run myos on the host itself" ;;
-n|--dry-run) DRYRUN=true; shift ;;
--color) MYOS_COLOR=$2; shift 2 ;;
--color=*) MYOS_COLOR=${1#--color=}; shift ;;
@@ -216,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|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
+36
View File
@@ -0,0 +1,36 @@
#shellcheck shell=sh
# myos export print every setting the requested stacks declare, as KEY=value.
#
# One call, so a Makefile can read the whole set at once:
# $(eval $(shell myos export STACK=host))
# Asking for each variable separately costs a process per variable.
myos_cmd_export() {
# shellcheck disable=SC2209 # these are literal format names, not commands
_fmt=sh
# shellcheck disable=SC2209
case ${MYOS_ARGS:-}${MYOS_VARS:-} in *--make*) _fmt=make ;; esac
for _v in $(myos_declared_defaults); do
_val=$(myos_var "$_v")
case $_fmt in
make)
# make would expand a $ and start a comment at a #, and := stops it
# from expanding the value again later
printf '%s := %s\n' "$_v" "$(printf '%s' "$_val" | sed -e 's/\$/$$/g' -e 's/#/\\#/g')" ;;
*) printf '%s=%s\n' "$_v" "$_val" ;;
esac
done
}
# myos_declared_defaults the variables the loaded hooks declare
myos_declared_defaults() {
for _ref in $MYOS_STACKS; do
for _d in $(myos_stack_dirs "$_ref"); do
for _f in "$_d"/_stack.sh "$_d/$(myos_stack_name "$_ref").sh"; do
[ -f "$_f" ] && sed -n 's/^myos_default_\([A-Za-z_][A-Za-z0-9_]*\)().*/\1/p' "$_f"
done
for _f in "$_d"/_stack.env "$_d/$(myos_stack_name "$_ref").env"; do
[ -f "$_f" ] && myos_dotenv_parse "$_f" | sed 's/=.*//'
done
done
done | sort -u
}
+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
}
+10
View File
@@ -0,0 +1,10 @@
#shellcheck shell=sh
# myos recreate remove the containers and create them again
# myos reload the same, under the name the make engine used
myos_cmd_recreate() {
# shellcheck source=lib/cmd/_compose.sh
. "$MYOS_ROOT/lib/cmd/_compose.sh"
MYOS_ARGS="--force-recreate ${MYOS_ARGS:-}"
myos_cmd_compose up
}
myos_cmd_reload() { myos_cmd_recreate; }
+4
View File
@@ -0,0 +1,4 @@
#shellcheck shell=sh
# myos reload: see lib/cmd/recreate.sh
# shellcheck source=lib/cmd/recreate.sh
. "$MYOS_ROOT/lib/cmd/recreate.sh"
+7
View File
@@ -0,0 +1,7 @@
#shellcheck shell=sh
# myos status what is running, under the name the make engine used for ps
myos_cmd_status() {
# shellcheck source=lib/cmd/_compose.sh
. "$MYOS_ROOT/lib/cmd/_compose.sh"
myos_cmd_compose ps
}
+4
View File
@@ -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:-}"; }
+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'
}
+3 -1
View File
@@ -30,7 +30,9 @@ NFS_HOST ?= host.docker.internal
SERVICES ?= $(DOCKER_SERVICES)
envprefix = $(foreach env,$(3),$(if $($(call UPPERCASE,$(1)_SERVICE_$(2)_$(env))),$(env)=$($(call UPPERCASE,$(1)_SERVICE_$(2)_$(env)))))
patsublist = $(patsubst $(1),$(2),$(firstword $(3)))$(foreach pattern,$(wordlist 2,255,$(3)),$(comma)$(patsubst $(1),$(2),$(pattern)))
## the replacement ends with the options, so an empty option list left a space
## before the comma joining two routes: "urlprefix-a/* ,urlprefix-b/*"
patsublist = $(subst $(space)$(comma),$(comma),$(patsubst $(1),$(2),$(firstword $(3)))$(foreach pattern,$(wordlist 2,255,$(3)),$(comma)$(patsubst $(1),$(2),$(pattern))))
servicenvs = $(foreach env,$(call UPPERCASE,$($(1)_SERVICE_$(2)_ENVS)),$(if $(3),$($(1)_SERVICE_$(env)_$(3)),$($(1)_SERVICE_$(2)_$(env))))
tagprefix = $(call urlprefix,$(or $($(call UPPERCASE,$(1)_SERVICE_$(2)_PATH)),$($(call UPPERCASE,$(1)_SERVICE_PATH))),$(or $($(call UPPERCASE,$(1)_SERVICE_$(2)_OPTS)),$($(call UPPERCASE,$(1)_SERVICE_OPTS)),$(call envprefix,$(1),$(2),allow auth deny preprend proto register strip)),$(or $(foreach env,$(3),$($(call UPPERCASE,$(1)_SERVICE_$(2)_$(env)))),$($(call UPPERCASE,$(1)_SERVICE_$(2)_URIS)),$(call uri,$(1),$(2))))
uri = $(foreach svc,$(1),$(patsubst %,$(addsuffix .,$(or $($(call UPPERCASE,$(svc)_SERVICE_$(2)_NAME)),$($(call UPPERCASE,$(svc)_SERVICE_NAME)),$(svc)))%,$(or $(3),$(APP_URI))))
+3 -1
View File
@@ -13,7 +13,9 @@ endif
setup-docker-group:
ifneq ($(DOCKER),)
ifeq ($(or $(filter $(USER),$(subst $(comma), ,$(shell awk -F':' '$$1 == "docker" {print $$4}' /etc/group))),$(filter 0,$(UID))),)
$(call ansible-user-add-groups,$(USER),docker)
$(RUN) $(SUDO) usermod -aG docker $(USER) 2>/dev/null \
|| $(RUN) $(SUDO) addgroup $(USER) docker 2>/dev/null \
|| $(call ERROR,unable to add user,$(USER),to group,docker)
$(call WARNING,user,$(USER),added in group,docker)
endif
ifeq ($(filter 0 $(DOCKER_GID),$(GIDS)),)
+23 -12
View File
@@ -1,10 +1,25 @@
##
# SSH
#
# The remote hosts used to come from AWS, through
# ssh-get-PrivateIpAddress-% -> aws-ec2-get-instances-PrivateIpAddress-%.
# make/apps/aws was removed and AWS_INSTANCE_IP is defined nowhere, so these
# targets looped over an empty list and exited 0 without doing anything.
# They now take SSH_HOSTS, and say so when it is empty rather than pretending
# to have connected.
# variable SSH_HOSTS: hosts the ssh targets act on, space separated
SSH_HOSTS ?= $(AWS_INSTANCE_IP)
# target ssh-hosts-check: Fail when no remote host is known
.PHONY: ssh-hosts-check
ssh-hosts-check:
$(if $(SSH_HOSTS),,$(call ERROR,no remote host: set SSH_HOSTS=host1 host2))
# target ssh: Call ssh-connect ARGS or SHELL
.PHONY: ssh
ssh: # ssh-get-PrivateIpAddress-$(SERVER_NAME) ## Connect to first remote host
$(call ssh-connect,$(AWS_INSTANCE_IP),$(if $(ARGS),$(ARGS),$(SHELL)))
ssh: ssh-hosts-check ## Connect to first remote host
$(call ssh-connect,$(SSH_HOSTS),$(if $(ARGS),$(ARGS),$(SHELL)))
# target ssh-add: Fire ssh-key and ssh-add file SSH_PRIVATE_KEYS in folder SSH_DIR
.PHONY: ssh-add
@@ -15,8 +30,8 @@ ssh-add: ssh-key
# target ssh-connect: Call ssh-connect make connect SERVICE
.PHONY: ssh-connect
ssh-connect: # ssh-get-PrivateIpAddress-$(SERVER_NAME)
$(call ssh-connect,$(AWS_INSTANCE_IP),make connect COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME) ENV=$(ENV) $(if $(SERVICE),SERVICE=$(SERVICE)))
ssh-connect: ssh-hosts-check
$(call ssh-connect,$(SSH_HOSTS),make connect COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME) ENV=$(ENV) $(if $(SERVICE),SERVICE=$(SERVICE)))
# target ssh-del: ssh-add -d file SSH_PRIVATE_KEYS in folder SSH_DIR
.PHONY: ssh-del
@@ -26,12 +41,8 @@ ssh-del:
# target ssh-exec: Call ssh-exec make exec SERVICE ARGS
.PHONY: ssh-exec
ssh-exec: # ssh-get-PrivateIpAddress-$(SERVER_NAME)
$(call ssh-exec,$(AWS_INSTANCE_IP),make exec COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME) ENV=$(ENV) $(if $(SERVICE),SERVICE=$(SERVICE)) $(if $(ARGS),ARGS='\''"$(ARGS)"'\''))
# target ssh-get-PrivateIpAddress-%: Fire aws-ec2-get-instances-PrivateIpAddress-%
.PHONY: ssh-get-PrivateIpAddress-%
ssh-get-PrivateIpAddress-%: aws-ec2-get-instances-PrivateIpAddress-%;
ssh-exec: ssh-hosts-check
$(call ssh-exec,$(SSH_HOSTS),make exec COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME) ENV=$(ENV) $(if $(SERVICE),SERVICE=$(SERVICE)) $(if $(ARGS),ARGS='\''"$(ARGS)"'\''))
# target ssh-key: Add ssh private key SSH_KEY to SSH_DIR
.PHONY: ssh-key
@@ -43,5 +54,5 @@ endif
# target ssh-run: Call ssh-run make run SERVICE ARGS
.PHONY: ssh-run
ssh-run: # ssh-get-PrivateIpAddress-$(SERVER_NAME)
$(call ssh-exec,$(AWS_INSTANCE_IP),make run $(if $(SERVICE),SERVICE=$(SERVICE)) $(if $(ARGS),ARGS='\''"$(ARGS)"'\''))
ssh-run: ssh-hosts-check
$(call ssh-exec,$(SSH_HOSTS),make run $(if $(SERVICE),SERVICE=$(SERVICE)) $(if $(ARGS),ARGS='\''"$(ARGS)"'\''))
+8 -2
View File
@@ -141,7 +141,9 @@ MACHINE ?= $(shell uname -m 2>/dev/null)
ifeq ($(SYSTEM),Darwin)
SED_SUFFIX := ''
STAT_FORMAT_ARG := -f
STAT_FORMAT_FILE := '%a %N'
# %m is the modification time; %a is the access time, which is what this used
# to ask for, so newer/older did not mean the same thing as on linux
STAT_FORMAT_FILE := '%m %N'
else
STAT_FORMAT_ARG := -c
STAT_FORMAT_FILE := '%Y %n'
@@ -199,6 +201,9 @@ rs256 = $(shell echo -n '$(1)' |openssl dgst -sha256 -binary -sign '$(2)')
JWT_HEADER = {"alg":"HS256","typ":"JWT"}
# macro JWT: Print Json Web Token for header $1 payload $2 and key $3
## a payload is JSON and holds commas, which make read as argument separators,
## so the token came out with an empty payload. Pass the payload in a variable
## and name it here rather than inlining it.
JWT := $(strip \
$(eval header := $(or $(1),$(JWT_HEADER))) \
$(eval payload := $(or $(2),$(JWT_PAYLOAD))) \
@@ -284,7 +289,8 @@ sed = $(RUN) sed -i $(SED_SUFFIX) '$(1)' $(2)
verle = [ -n "$(1)" ] && [ "$(1)" = "$(shell echo -e "$(1)\n$(2)" |sort -V |head -n1)" ]
# macro verlt: Return true when version 1 < 2
verlt = [ "$(1)" = "$(2)" ] && return 1 || $(call verlte,$(1),$(2))
## it was calling verlte, which does not exist, and returning from no function
verlt = [ "$(1)" != "$(2)" ] && $(call verle,$(1),$(2))
# function conf: Extract variable=value line from configuration files
## it prints the line with variable 3 definition from block 2 in file 1
+13 -2
View File
@@ -3,7 +3,14 @@
set -eu
# define MYOS path
MYOS="$(dirname "$(readlink "$0" || echo "$0")")"
## readlink without -f only followed one level and only an absolute link, so a
## relative or chained symlink pointed the framework at the wrong directory
MYOS="$0"
while [ -L "$MYOS" ]; do
_link="$(readlink "$MYOS")"
case "$_link" in /*) MYOS="$_link" ;; *) MYOS="$(dirname "$MYOS")/$_link" ;; esac
done
MYOS="$(cd "$(dirname "$MYOS")" && pwd -P)"
# load system config: /etc/conf.d/myos (openrc convention) first, then the
# debian-style /etc/default/myos as fallback
@@ -17,4 +24,8 @@ MYOS_CONF=/etc/conf.d/myos
# call myos Makefile: a WORKDIR from the config or environment wins over PWD,
# so a machine can pin its deployment dir and run myos from anywhere
IFS=$'\n'; exec env $(cat "$MYOS_CONF" 2>/dev/null) MYOS=. WORKDIR="${WORKDIR:-${PWD}}" make -esC "${MYOS:-.}" "$@"
## a comment or a blank line in the config used to become the program env(1)
## was asked to run, so the whole command failed
IFS=$'\n'
exec env $(sed -e '/^[[:space:]]*#/d' -e '/^[[:space:]]*$/d' "$MYOS_CONF" 2>/dev/null) \
MYOS=. WORKDIR="${WORKDIR:-${PWD}}" make -esC "${MYOS:-.}" "$@"
+11 -4
View File
@@ -36,10 +36,17 @@ define make
$(MYOS_BIN) $(MYOS_ARGS) $(1)
endef
# function myos-var: the value myos resolves for a variable.
# A stack keeps its settings in hooks that only myos reads, so a .mk target
# asks for them rather than defining them itself:
# $(call myos-var,HOST_DOCKER_VOLUME)
# The settings of the stacks, read once and evaluated here.
# A stack keeps its settings in hooks that only myos reads; asking for them one
# at a time costs a process per variable, so they come in a single call.
## Written while this file is read, not as a target: a target would collide
## with the catch-all rule at the bottom, which hands anything else to myos.
MYOS_SETTINGS ?= .myos.settings.mk
MYOS_SETTINGS_FILE := $(shell $(MYOS_BIN) --color=never $(MYOS_ARGS) export --make > $(MYOS_SETTINGS) 2>/dev/null && echo $(MYOS_SETTINGS))
-include $(MYOS_SETTINGS_FILE)
# function myos-var: the value myos resolves for one variable, when a single
# lookup is cheaper than the whole set
myos-var = $(shell $(MYOS_BIN) --color=never $(MYOS_ARGS) env $(1) | awk '{print $$2}')
# target help: List the myos commands
+4
View File
@@ -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 <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**,
including the certificates.
- 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 |
| `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 |
+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
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
A group is a lowercase name whose value lists stacks. It can live in a `.env`,
+7
View File
@@ -84,3 +84,10 @@ shim-env | shim-project | @make env ARGS=COMPOSE_PROJECT
chain-build-up-logs | host-project | build up logs host/fabio
chain-up-group | host-project | up ps host
chain-print-two | host-project | print-COMPOSE_PROJECT_NAME print-APP STACK=host/consul
mk-ssh-no-hosts | app-nogit | ssh
mk-ssh-with-hosts | app-nogit | ssh SSH_HOSTS=example.test ARGS=id
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
@@ -0,0 +1,2 @@
[exit 0]
@@ -0,0 +1,4 @@
docker network create tester-local
docker network create testhost
docker compose -f @WD@/stack/host/consul.yml -f @MYOS@/share/compose/networks.yml -p testhost --project-directory @WD@/stack/host up -d --force-recreate
[exit 0]
+2
View File
@@ -0,0 +1,2 @@
docker compose -f @WD@/stack/host/consul.yml -f @MYOS@/share/compose/networks.yml -p testhost --project-directory @WD@/stack/host ps
[exit 0]
@@ -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]
@@ -0,0 +1,3 @@
ERROR: unknown command: ssh
ERROR: to act on a stack of that name, say what to do: myos up ssh
[exit 2]
@@ -0,0 +1,3 @@
ERROR: unknown command: ssh
ERROR: to act on a stack of that name, say what to do: myos up ssh
[exit 2]
+2
View File
@@ -0,0 +1,2 @@
WARNING: myos[0] export-rule-exists: target export unavailable in app myos
[exit 0]
+7
View File
@@ -0,0 +1,7 @@
make -o docker-stack-recreate MAKE_OLDFILE=docker-stack-recreate ENV=local DOCKER_COMPOSE=docker --log-level=error compose docker-compose-recreate STACK=myos APP_NAME=myos
docker --log-level=error compose --ansi=auto -f @MYOS@/share/compose/networks.yml -p tester-myos-local rm -fs
sh -c docker network create tester-local >/dev/null 2>&1
sh -c docker network create testhost >/dev/null 2>&1
docker --log-level=error compose --ansi=auto -f @MYOS@/share/compose/networks.yml -p tester-myos-local up -d
WARNING: myos[0] host/consul-rule-exists: target host/consul unavailable in app myos
[exit 0]
+4
View File
@@ -0,0 +1,4 @@
make -o docker-stack-ps MAKE_OLDFILE=docker-stack-ps ENV=local DOCKER_COMPOSE=docker --log-level=error compose docker-compose-ps STACK=myos APP_NAME=myos
docker --log-level=error compose --ansi=auto -f @MYOS@/share/compose/networks.yml -p tester-myos-local ps
WARNING: myos[0] host/consul-rule-exists: target host/consul unavailable in app myos
[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]
+3
View File
@@ -0,0 +1,3 @@
ERROR: myos[0] ssh-hosts-check: no remote host: set SSH_HOSTS=host1 host2
make: *** [ssh-hosts-check] Error 2
[exit 2]
@@ -0,0 +1,4 @@
ERROR: myos[0] ssh: Unable to find docker tester-example-test
make: *** [ssh] Error 2
[exit 2]
+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