Add --publish: the one ingress control
Everything else in the file gates egress, which left an everyday request unanswered: the agent starts a dev server inside the sandbox and a human wants to look at it. This puts a listener on the host that forwards into the container, so a browser, curl or an API tester reaches the server the sandboxed process runs. `--publish [ADDR:]HOST:CONTAINER`, config `publish` and AISB_PUBLISH, repeatable, with the cost named in the flag: it is ingress, so whatever that server serves is reachable from the address it binds. The default address is 127.0.0.1 because a bare podman -p binds every interface (measured) -- a port committed in a repo config must not quietly be a port on the LAN. publish_addr changes the default machine-wide; a spec with a non-loopback address warns on every run. The grammar is smaller than podman's on purpose: one port per entry, no ranges, no /udp, no `*` -- this list is reviewed line by line, and typing 0.0.0.0 is the whole warning. An address means three parts, so `127.0.0.1:8000` is refused rather than guessed. No -p alias: that flag belongs to the harness (claude -p prints). Fail closed, as elsewhere: --net none cannot forward a port and podman discards the mapping while starting the container anyway, so "it ran" would mean "nothing is listening"; a host port already in use is named before the container starts, not after a build; pick_port() will not hand the gate a port this run publishes. The container is labelled with the ports it was given, and doctor reports them. Inside, publish_rules() accepts tcp on the published ports in the input chain and never the output one. Measured (docs/networking.md): pasta delivers the connection over loopback, so that rule counts 0 packets on the reference host; it is there for a host or a bridge network that delivers on the interface, and so that `nft list table inet aisb` answers "what can reach this container". The ports are validated again inside the entrypoint, because a ruleset is not somewhere to splice an unvalidated string. And the measurement that makes a configured preview look broken: a server bound to 127.0.0.1 inside answers inside and refuses the published port outside, so dev servers must bind 0.0.0.0 -- both skills say so in the change that writes the port. Tests. Unit: the grammar and every port list that looks right and is not, config/env/flag precedence and dedup, the canonical spec in --print-policy, the `# ingress:` header, the flags reaching the invocation before the image, egress enforcement byte-identical with a port open, which chain the rule lands in and that a non-numeric port never reaches nft as code. e2e (tests/e2e/13_publish.sh): one real container with enforce=nft where the host gets its 200 and the same run still refuses a domain outside the allowlist at 403, the server runs as uid 1000 with no capabilities so it cannot widen its own port, and the host listener is gone when the container exits. LLM-Generated: true
This commit is contained in:
+255
-6
@@ -199,6 +199,36 @@ NETWORK
|
||||
Domains are resolved once, on the host, at start, and
|
||||
DNS is opened up. Weaker than the gate for CDNs.
|
||||
|
||||
PORTS (INGRESS)
|
||||
Everything above is egress: what the sandboxed process may reach. This is the other
|
||||
direction, for looking at a server the sandbox is running.
|
||||
--publish [ADDR:]HOST:CONTAINER
|
||||
Forward a host port into the container, so a client on the
|
||||
host -- a browser, curl, an API tester -- reaches a server
|
||||
the sandboxed process listens on. Repeatable.
|
||||
8000 host 8000 -> container 8000
|
||||
18080:8000 host 18080 -> container 8000
|
||||
0.0.0.0:18080:8000 the same on every host interface
|
||||
ADDR defaults to 127.0.0.1 (publish_addr / AISB_PUBLISH_ADDR
|
||||
to change it), because a port opened in a committed config
|
||||
must not quietly be a port opened on your network. Anything
|
||||
not loopback warns. One port per entry: no ranges, no
|
||||
/udp -- this list is reviewed line by line, and a range is a
|
||||
line nobody reads. An address means three parts, so
|
||||
'127.0.0.1:8000' is refused rather than guessed, and podman's '*' is
|
||||
refused too -- typing 0.0.0.0 is the warning.
|
||||
It is ingress, and that is the cost: whatever that server
|
||||
serves, or accepts, is reachable from outside the container.
|
||||
Needs --net allow or --net full. With --net none podman drops
|
||||
port mappings with a warning to its own stderr, so
|
||||
ai-sandbox refuses to start instead.
|
||||
The server must listen on 0.0.0.0 inside the container: pasta
|
||||
delivers a published connection to the container's own
|
||||
address, so a 127.0.0.1-only server answers 'connection
|
||||
refused' (measured; docs/networking.md).
|
||||
There is no -p alias on purpose: -p belongs to the harness
|
||||
(claude -p prints), and an unknown flag already passes to it.
|
||||
|
||||
FILESYSTEM
|
||||
The current directory is bind-mounted at the path it has on the host and is the
|
||||
working directory, so pwd, prompts and window titles name the project instead of
|
||||
@@ -309,6 +339,7 @@ CONFIG
|
||||
Every default can also be set as an environment variable:
|
||||
AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY,
|
||||
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, AISB_THINKING,
|
||||
AISB_PUBLISH (comma list, same shapes as --publish), AISB_PUBLISH_ADDR (127.0.0.1),
|
||||
AISB_AGENTS, AISB_AGENTS_DIR, AISB_AGENTS_DEST, ...
|
||||
Base images: AISB_BASE_IMAGE, AISB_BASE_TTL_DAYS (7; 0 every build, -1 never),
|
||||
AISB_BASE_AUTO_UPDATE (0 keeps the recorded digest), AISB_BASE_PULL_FALLBACK
|
||||
@@ -331,9 +362,9 @@ EOF
|
||||
# config files
|
||||
# ===========================================================================
|
||||
declare -A CFG=()
|
||||
declare -a CFG_allow=() CFG_env=() CFG_mount=() CFG_install=() CFG_label=()
|
||||
declare -a CFG_allow=() CFG_env=() CFG_mount=() CFG_install=() CFG_label=() CFG_publish=()
|
||||
|
||||
cfg_accum_keys="allow env mount install label"
|
||||
cfg_accum_keys="allow env mount install label publish"
|
||||
|
||||
cfg_read_kv() { # `key = value` file
|
||||
local file="$1" line key val
|
||||
@@ -355,7 +386,8 @@ cfg_read_kv() { # `key = value` file
|
||||
case "$key" in
|
||||
net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\
|
||||
project_dest|selinux|home_cache|gate_addr|gate_port|extra_args|base_ttl_days|base_auto_update|\
|
||||
pi_config|pi_token_env|pi_token_auto|pi_agent_dir|thinking|agents|agents_dir|agents_dest) CFG["$key"]="$val" ;;
|
||||
pi_config|pi_token_env|pi_token_auto|pi_agent_dir|thinking|agents|agents_dir|agents_dest|\
|
||||
publish_addr) CFG["$key"]="$val" ;;
|
||||
*) warn "$file: unknown key: $key" ;;
|
||||
esac
|
||||
done < "$file"
|
||||
@@ -388,6 +420,7 @@ load_config() {
|
||||
for e in ${AISB_ENV:-}; do [[ -n "$e" ]] && CFG_env+=("$(trim "$e")"); done
|
||||
for e in ${AISB_MOUNT:-}; do [[ -n "$e" ]] && CFG_mount+=("$(trim "$e")"); done
|
||||
for e in ${AISB_INSTALL:-}; do [[ -n "$e" ]] && CFG_install+=("$(trim "$e")"); done
|
||||
for e in ${AISB_PUBLISH:-}; do [[ -n "$e" ]] && CFG_publish+=("$(trim "$e")"); done
|
||||
IFS=$' \t\n'
|
||||
return 0
|
||||
}
|
||||
@@ -403,12 +436,13 @@ OPT_TTY="" OPT_INTERACTIVE="" OPT_PRINT_EP=0 OPT_PRINT_CF=0 OPT_PASSTHROUGH=0
|
||||
OPT_AGENTS="" OPT_AGENTS_DIR=""
|
||||
OPT_PI_CONFIG="" OPT_PI_TOKEN_AUTO="" OPT_PI_TOKEN_ENV="" OPT_THINKING=""
|
||||
declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=()
|
||||
declare -a OPT_publish=()
|
||||
COMMAND=()
|
||||
|
||||
strict_on() { [[ "${OPT_STRICT:-0}" == 1 || "${AISB_STRICT_ARGS:-0}" == 1 ]]; }
|
||||
|
||||
KNOWN_OPTS=(
|
||||
net allow allow-file proxy no-gate env env-file mount no-cwd agents no-agents agents-dir
|
||||
net allow allow-file proxy no-gate publish env env-file mount no-cwd agents no-agents agents-dir
|
||||
workdir home-cache ephemeral-home
|
||||
harness install base-image cpus memory pids tmpfs-size name label keep read-only shell
|
||||
interactive no-tty dry-run print-policy no-build allow-unenforced strict-args insecure-keep-root
|
||||
@@ -447,6 +481,8 @@ parse_args() {
|
||||
--proxy) need "$1" "${2:-}"; OPT_PROXY="$2"; shift 2 ;;
|
||||
--proxy=*) OPT_PROXY="${1#*=}"; shift ;;
|
||||
--no-gate) OPT_GATE=1; shift ;;
|
||||
--publish) need "$1" "${2:-}"; OPT_publish+=("$2"); shift 2 ;;
|
||||
--publish=*) OPT_publish+=("${1#*=}"); shift ;;
|
||||
--env) need "$1" "${2:-}"; OPT_env+=("$2"); shift 2 ;;
|
||||
--env=*) OPT_env+=("${1#*=}"); shift ;;
|
||||
--env-file) need "$1" "${2:-}"; OPT_env+=("@${2}"); shift 2 ;;
|
||||
@@ -508,7 +544,7 @@ parse_args() {
|
||||
# resolve
|
||||
# ===========================================================================
|
||||
declare -A POL=()
|
||||
declare -a P_allow=() P_env=() P_mount=() P_install=() P_label=()
|
||||
declare -a P_allow=() P_env=() P_mount=() P_install=() P_label=() P_publish=()
|
||||
|
||||
cfgv() { local k="$1" d="$2"; printf '%s' "${CFG[$k]:-$d}"; }
|
||||
|
||||
@@ -630,6 +666,134 @@ resolve_workdir() { # sets POL[workdir] and POL[workdir_note]; needs POL[cwd]
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# published ports (ingress)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Every other control in this file is egress. This one is the other direction: a listener
|
||||
# on the host that forwards into the container, so a browser on the host can look at the
|
||||
# dev server the sandbox is running.
|
||||
#
|
||||
# What it does and does not cost. It opens nothing outward: the netfilter ruleset, the
|
||||
# gate and the allowlist are untouched, and a published container port is reachable only
|
||||
# through the address named in the spec. What it does mean is that a client outside the
|
||||
# container can reach a process inside it, so whatever that server serves (or accepts) is
|
||||
# reachable from that address. That is the point of the flag and the whole of its cost,
|
||||
# which is why the default address is loopback and a non-loopback one is in the spec text
|
||||
# rather than in a default.
|
||||
#
|
||||
# Shapes, and the address is what keeps them unambiguous:
|
||||
#
|
||||
# 8000 host 8000 -> container 8000, on publish_addr (127.0.0.1)
|
||||
# 18080:8000 host 18080 -> container 8000
|
||||
# 0.0.0.0:18080:8000 the same, on every host interface
|
||||
#
|
||||
# An address first means three parts, so `127.0.0.1:8000` is refused with a message
|
||||
# instead of meaning two different things. Ranges and /udp are podman's syntax, not ours:
|
||||
# this list is a policy a human reviews, and a range is a line nobody reads.
|
||||
#
|
||||
# Measured on the reference host (docs/networking.md): pasta forwards a published
|
||||
# connection into the namespace over loopback, so the entrypoint's `iifname "lo" accept`
|
||||
# already carries it and the input hook sees no packet for it. The explicit accept in
|
||||
# gen_rules() is for a host where it arrives on the container's own interface instead, and
|
||||
# so that `nft list table inet aisb` says which ports were opened.
|
||||
declare -a PUB_SPEC=() PUB_HOST_PORTS=() PUB_CTR_PORTS=()
|
||||
PUB_LAN=0
|
||||
|
||||
pub_is_loopback() { # an address the LAN cannot reach
|
||||
local a="${1#\[}"; a="${a%\]}"
|
||||
case "$a" in
|
||||
127.*|localhost|ip6-localhost|ip6-loopback|::1) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pub_port_ok() { [[ "$1" =~ ^[0-9]{1,5}$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )); }
|
||||
|
||||
resolve_publish() { # fills PUB_SPEC / PUB_HOST_PORTS / PUB_CTR_PORTS / PUB_LAN
|
||||
PUB_SPEC=(); PUB_HOST_PORTS=(); PUB_CTR_PORTS=(); PUB_LAN=0
|
||||
local spec addr rest host p hp cp bind_arg seen=" " seen_ctr=" " n
|
||||
local -a parts
|
||||
local bind="${POL[publish_addr]:-127.0.0.1}"
|
||||
for spec in ${P_publish[@]+"${P_publish[@]}"}; do
|
||||
spec="$(trim "$spec")"
|
||||
[[ -n "$spec" ]] || continue
|
||||
[[ "$spec" != */* ]] || die "--publish: no protocol suffix in '${spec}': one port per entry"
|
||||
# Split on ':' -- except that an ipv6 address is full of them, so a leading bracketed
|
||||
# group is taken off first and what is left is the ordinary two fields.
|
||||
addr=""
|
||||
if [[ "$spec" == \[* ]]; then
|
||||
[[ "$spec" == *]:* ]] || die "--publish: expected [addr]:host:container, got '${spec}'"
|
||||
addr="${spec%%]:*}]"
|
||||
rest="${spec#*]:}"
|
||||
else
|
||||
rest="$spec"
|
||||
fi
|
||||
# `read -a` drops a trailing empty field, so '8000:' would quietly become 8000:8000.
|
||||
[[ "$rest" != *: ]] || die "--publish: empty field in '${spec}'"
|
||||
parts=()
|
||||
IFS=: read -r -a parts <<< "$rest" || true
|
||||
n=${#parts[@]}
|
||||
if [[ -n "$addr" ]]; then
|
||||
(( n == 2 )) || die "--publish: cannot read '${spec}'
|
||||
with an address it is three parts: [addr]:host-port:container-port"
|
||||
elif (( n == 3 )); then # 0.0.0.0:18080:8000
|
||||
addr="${parts[0]}"
|
||||
parts=("${parts[1]}" "${parts[2]}")
|
||||
n=2
|
||||
elif (( n != 1 && n != 2 )); then
|
||||
die "--publish: cannot read '${spec}'
|
||||
write one of: 8000 18080:8000 0.0.0.0:18080:8000"
|
||||
fi
|
||||
for p in "${parts[@]}"; do
|
||||
[[ -n "$p" ]] || die "--publish: empty field in '${spec}'"
|
||||
done
|
||||
hp="${parts[0]}"; cp="${parts[1]:-$hp}"
|
||||
if [[ "$hp" == *[:.]* ]]; then
|
||||
die "--publish: '${hp}' looks like an address, and an address needs three parts:
|
||||
addr:host-port:container-port e.g. 0.0.0.0:8000:8000 ([ipv6] in brackets)"
|
||||
fi
|
||||
pub_port_ok "$hp" || die "--publish: host port is not a port number: '${hp}'
|
||||
one port per entry, 1-65535, no ranges: --publish 18080:8000"
|
||||
pub_port_ok "$cp" || die "--publish: container port is not a port number: '${cp}'"
|
||||
hp=$((10#$hp)); cp=$((10#$cp))
|
||||
if [[ -z "$addr" ]]; then
|
||||
addr="$bind"
|
||||
else
|
||||
host="${addr#\[}"; host="${host%\]}" # [::1] and ::1 are the same address to bind
|
||||
[[ "$host" =~ ^[0-9A-Za-z_.:%-]+$ ]] \
|
||||
|| die "--publish: '${addr}' is not an address to bind (an IP, or [ipv6])"
|
||||
# `8000:8000:8000` parses as addr:host:container and then hands podman a "bind address"
|
||||
# of 8000, which fails there with an error that mentions none of our words. An address has
|
||||
# to look like one. (`*` for "every interface" is podman's shorthand and is refused here on
|
||||
# purpose: writing 0.0.0.0 makes the person typing it read what they are opening.)
|
||||
case "$host" in
|
||||
*:*|*.*|localhost|ip6-localhost|ip6-loopback) ;;
|
||||
*) die "--publish: '${addr}' is not an address in '${spec}'
|
||||
write a port (8000), a pair (18080:8000) or an address with two ports
|
||||
(0.0.0.0:18080:8000, or [::1]:18080:8000)"
|
||||
esac
|
||||
addr="$host"
|
||||
fi
|
||||
pub_is_loopback "$addr" || PUB_LAN=1
|
||||
[[ "$seen" != *" ${addr}:${hp} "* ]] \
|
||||
|| die "--publish: host port ${addr}:${hp} is published twice (two container ports?)"
|
||||
seen+="${addr}:${hp} "
|
||||
PUB_HOST_PORTS+=("$hp")
|
||||
if [[ "$seen_ctr" != *" ${cp} "* ]]; then
|
||||
seen_ctr+="${cp} "
|
||||
PUB_CTR_PORTS+=("$cp")
|
||||
fi
|
||||
# An ipv6 bind keeps its brackets on the way to podman, which splits on ':' too.
|
||||
bind_arg="$addr"
|
||||
[[ "$addr" == *:* && "$addr" != \[* ]] && bind_arg="[$addr]"
|
||||
PUB_SPEC+=("${bind_arg}:${hp}:${cp}")
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# The port a published mapping binds on the host. Nothing secret: a port number.
|
||||
publish_text() { join ' ' ${PUB_SPEC[@]+"${PUB_SPEC[@]}"}; }
|
||||
|
||||
resolve_opts() {
|
||||
POL[cwd]="$PWD"
|
||||
POL[net]="${OPT_NET:-${AISB_NET:-$(cfgv net allow)}}"
|
||||
@@ -685,6 +849,9 @@ resolve_opts() {
|
||||
POL[agents_src]=""
|
||||
[[ "${POL[agents]}" == 1 ]] && POL[agents_src]="$(agents_src_of)"
|
||||
POL[gate_addr]="${AISB_GATE_ADDR:-$(cfgv gate_addr 100.64.0.1)}"
|
||||
# The address a bare --publish 8000 binds on the host. Loopback: a repo config that
|
||||
# opens a port for preview must not be a port opened on the local network.
|
||||
POL[publish_addr]="${AISB_PUBLISH_ADDR:-$(cfgv publish_addr 127.0.0.1)}"
|
||||
POL[gate_port]="${AISB_GATE_PORT:-$(cfgv gate_port 18081)}"
|
||||
POL[extra_args]="${AISB_EXTRA_ARGS:-$(cfgv extra_args '')}"
|
||||
POL[proxy]="${OPT_PROXY}"
|
||||
@@ -700,6 +867,7 @@ resolve_opts() {
|
||||
P_mount=(${CFG_mount[@]+"${CFG_mount[@]}"} ${OPT_mount[@]+"${OPT_mount[@]}"})
|
||||
P_install=(${CFG_install[@]+"${CFG_install[@]}"} ${OPT_install[@]+"${OPT_install[@]}"})
|
||||
P_label=(${CFG_label[@]+"${CFG_label[@]}"} ${OPT_label[@]+"${OPT_label[@]}"})
|
||||
P_publish=(${CFG_publish[@]+"${CFG_publish[@]}"} ${OPT_publish[@]+"${OPT_publish[@]}"})
|
||||
|
||||
local f line
|
||||
for f in ${OPT_allow_files[@]+"${OPT_allow_files[@]}"}; do
|
||||
@@ -710,7 +878,8 @@ resolve_opts() {
|
||||
done < "$f"
|
||||
done
|
||||
|
||||
dedup P_allow; dedup P_env; dedup P_mount; dedup P_install; dedup P_label
|
||||
dedup P_allow; dedup P_env; dedup P_mount; dedup P_install; dedup P_label; dedup P_publish
|
||||
resolve_publish
|
||||
set_selinux "${POL[selinux]}"
|
||||
}
|
||||
|
||||
@@ -733,6 +902,33 @@ validate_opts() {
|
||||
if [[ "${POL[net]}" == full ]]; then
|
||||
warn "--net full: the container has unrestricted internet access"
|
||||
fi
|
||||
# Published ports: they need a network to arrive on, and a non-loopback bind needs a
|
||||
# word. Both are said here rather than at the container, because "it started" is the
|
||||
# worst shape of this: measured, `--network=none -p 8000:8000` prints one line about
|
||||
# discarding the mapping and runs the container regardless.
|
||||
if ((${#PUB_SPEC[@]})); then
|
||||
if [[ "${POL[net]}" == none ]]; then
|
||||
die "--publish needs a network: --net none cannot forward a port at all.
|
||||
podman discards the mapping and starts the container anyway, so the run would look
|
||||
fine and nothing would be listening. Use --net allow, or drop the publish entries."
|
||||
fi
|
||||
if (( PUB_LAN )); then
|
||||
warn "--publish: $(publish_text)
|
||||
a non-loopback address means anything that can route to this machine can reach the
|
||||
server the sandbox runs. Loopback is the default because that is every client the
|
||||
pattern needs; if you meant it, the value says so."
|
||||
fi
|
||||
# The host port has to be free now, or podman fails to bind at container start --
|
||||
# after any build. Previews never check: they never bind anything either.
|
||||
if (( OPT_DRY == 0 && OPT_PRINT == 0 && OPT_PRINT_EP == 0 && OPT_PRINT_CF == 0 )); then
|
||||
local pp
|
||||
for pp in "${PUB_HOST_PORTS[@]}"; do
|
||||
port_free "$pp" || die "--publish: host port ${pp} is already in use
|
||||
give the container a different host port (--publish OTHER:${pp}), or find what holds
|
||||
it (ai-sandbox ps lists sandbox containers left behind by --keep)."
|
||||
done
|
||||
fi
|
||||
fi
|
||||
# An explicit --agents-dir that resolves to nothing is a typo, so it is an error. The
|
||||
# *default* pointing at a directory this host does not have is normal: the bridge
|
||||
# switches itself off, it does not fail a run.
|
||||
@@ -898,6 +1094,7 @@ write_policy_files() {
|
||||
printf 'AISB_ALLOW_DNS=%s\n' "$ALLOW_DNS"
|
||||
printf 'AISB_PROXY_IPS=%s\n' "${PROXY_IPS[*]-}"
|
||||
printf 'AISB_PROXY_PORTS=%s\n' "${PROXY_PORTS[*]-}"
|
||||
printf 'AISB_PUBLISH_PORTS=%s\n' "${PUB_CTR_PORTS[*]-}"
|
||||
printf 'AISB_PI_THINKING=%s\n' "${POL[thinking_eff]:-}"
|
||||
} > "$SESSION_MOUNT/policy.env"
|
||||
chmod 644 "$SESSION_MOUNT/policy.env"
|
||||
@@ -938,6 +1135,26 @@ GID_TARGET="${AISB_GID:-1000}"
|
||||
UNENFORCED="${AISB_UNENFORCED:-0}"
|
||||
KEEP_ROOT="${AISB_KEEP_ROOT:-0}"
|
||||
|
||||
# Ingress for the ports the host publishes into this container. It changes nothing on the
|
||||
# reference host -- measured, pasta forwards a published connection into the namespace over
|
||||
# loopback, so the iifname "lo" accept already carries it and the input hook sees no packet
|
||||
# for it. Two reasons the rule is there anyway: another host (or a bridge network) can
|
||||
# deliver it on the container's own interface, where policy drop would leave a port that
|
||||
# looks configured and answers nothing; and a ruleset that names the opened ports is the
|
||||
# audit trail, so `nft list table inet aisb` answers "what can reach this container".
|
||||
#
|
||||
# Ports only, and validated again here: the policy file is written by the host and mounted
|
||||
# read-only, but a ruleset is not somewhere to trust a string you spliced.
|
||||
publish_rules() {
|
||||
local p pub=""
|
||||
for p in ${AISB_PUBLISH_PORTS:-}; do
|
||||
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
|
||||
[[ ",$pub," == *",$p,"* ]] || pub+="${pub:+,}$p"
|
||||
done
|
||||
[[ -n "$pub" ]] || return 0
|
||||
echo " meta l4proto tcp tcp dport { $pub } counter accept comment \"aisb-publish\""
|
||||
}
|
||||
|
||||
gen_rules() {
|
||||
local dest ports fam
|
||||
echo 'table inet aisb {'
|
||||
@@ -975,6 +1192,7 @@ gen_rules() {
|
||||
echo ' iifname "lo" accept'
|
||||
echo ' ct state established,related accept'
|
||||
echo ' meta l4proto ipv6-icmp icmpv6 type { nd-neighbor-advert, nd-router-advert, echo-reply } accept'
|
||||
publish_rules
|
||||
echo ' }'
|
||||
echo '}'
|
||||
}
|
||||
@@ -1003,6 +1221,9 @@ fi
|
||||
|
||||
n_direct=$(cat "$POLICY_DIR"/direct4.list "$POLICY_DIR"/direct6.list 2>/dev/null | grep -cvE '^[[:space:]]*(#|$)' || true)
|
||||
log "egress net=$NET enforce=$ENFORCE proxy=[${AISB_PROXY_IPS:-none}:${AISB_PROXY_PORTS:-none}] direct=$n_direct dns=${AISB_ALLOW_DNS:-0}"
|
||||
# The other direction, when the host was asked to forward one. stderr like every other
|
||||
# line the sandbox says about itself: stdout belongs to the command that ran.
|
||||
[[ -z "${AISB_PUBLISH_PORTS:-}" ]] || log "ingress: input accepts tcp ${AISB_PUBLISH_PORTS} (ports published by the host)"
|
||||
|
||||
if [[ "$KEEP_ROOT" == 1 ]]; then
|
||||
log "WARNING: --insecure-keep-root: running as uid $(id -u) with capabilities"
|
||||
@@ -1782,6 +2003,9 @@ port_free() { ! ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${1}\$
|
||||
pick_port() {
|
||||
local p="${POL[gate_port]}" i
|
||||
for ((i = 0; i < 64; i++)); do
|
||||
# A port this run publishes will not be free by the time the container starts, and the
|
||||
# gate is bound first: take the next one rather than lose the run over it.
|
||||
if [[ " ${PUB_HOST_PORTS[*]-} " == *" $p "* ]]; then p=$((p + 1)); continue; fi
|
||||
port_free "$p" && { printf '%s\n' "$p"; return 0; }
|
||||
p=$((p + 1))
|
||||
done
|
||||
@@ -1942,6 +2166,9 @@ build_argv() {
|
||||
--label "aisb.net=${POL[net]}" --label "aisb.harness=${POL[harness]}")
|
||||
local l
|
||||
for l in ${P_label[@]+"${P_label[@]}"}; do ARGV+=(--label "$l"); done
|
||||
# Which ports this container was given on the host, for 'podman ps' and 'ai-sandbox clean':
|
||||
# a published port outliving its session is a listener nobody is watching.
|
||||
((${#PUB_CTR_PORTS[@]})) && ARGV+=(--label "aisb.publish=${PUB_CTR_PORTS[*]// /,}")
|
||||
[[ "${OPT_KEEP:-0}" == 1 ]] || ARGV+=(--rm)
|
||||
ARGV+=(--hostname aisb)
|
||||
ARGV+=(--userns "keep-id:uid=${AISB_UID},gid=${AISB_GID}")
|
||||
@@ -1955,6 +2182,11 @@ build_argv() {
|
||||
ARGV+=(--pids-limit "${POL[pids]}" --memory "${POL[memory]}" --cpus "${POL[cpus]}")
|
||||
ARGV+=(--tmpfs "/tmp:rw,nosuid,nodev,size=${POL[tmpfs]}")
|
||||
ARGV+=(--network "$net_arg")
|
||||
# Published ports: ingress, decided per port, bound to loopback unless the spec named an
|
||||
# address (see resolve_publish). podman/pasta does the forwarding; the in-container
|
||||
# ruleset is told which ports to accept so it can say so in its own listing.
|
||||
local pspec
|
||||
for pspec in ${PUB_SPEC[@]+"${PUB_SPEC[@]}"}; do ARGV+=(--publish "$pspec"); done
|
||||
ARGV+=(--stop-signal SIGTERM --pull missing)
|
||||
|
||||
if [[ -n "${OPT_TTY:-}" ]]; then
|
||||
@@ -2037,6 +2269,8 @@ print_argv() {
|
||||
"${POL[net]}" "${PROXY_IPS[0]-none}:${PROXY_PORTS[0]-none}" \
|
||||
"$(printf '%s ' ${GATE_DOMAINS[@]+"${GATE_DOMAINS[@]}"})" \
|
||||
"$(printf '%s ' ${DIRECT4[@]+"${DIRECT4[@]}"})"
|
||||
# ingress on its own line, because it is the one thing here that lets something in
|
||||
printf '# ingress: publish=[%s]\n' "$(printf '%s ' ${PUB_SPEC[@]+"${PUB_SPEC[@]}"})"
|
||||
printf '# env (%d, passed via 0600 file): %s\n' "${#ENV_NAMES[@]}" \
|
||||
"$(printf '%s ' ${ENV_NAMES[@]+"${ENV_NAMES[@]}"})"
|
||||
# A fallback is worth a line here: the invocation on its own only shows /project, not
|
||||
@@ -2065,6 +2299,7 @@ print_policy() {
|
||||
printf 'container=%s\n' "${POL[name]}"
|
||||
printf 'net=%s\n' "${POL[net]}"
|
||||
printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}"
|
||||
printf 'publish=%s\n' "$(publish_text)"
|
||||
printf 'harness=%s\n' "${POL[harness]}"
|
||||
printf 'command=%s\n' "$(printf '%s ' ${COMMAND[@]+"${COMMAND[@]}"})"
|
||||
printf 'workdir=%s\n' "${POL[workdir]}"
|
||||
@@ -2277,6 +2512,20 @@ cmd_doctor() {
|
||||
chk INFO "no allowlist configured yet (--allow, .ai-sandbox/allow, $AISB_CONFIG_DIR/allow)"
|
||||
fi
|
||||
|
||||
# Ingress, reported like the allowlist: a port opened in a config file is a decision the
|
||||
# next reader should see without starting anything. Doctor never binds anything, so the
|
||||
# occupancy question is asked of the machine here and now, as a warning, not a refusal.
|
||||
if ((${#PUB_SPEC[@]})); then
|
||||
chk INFO "publish (ingress): $(publish_text)"
|
||||
(( PUB_LAN )) && chk WARN "a published port with a non-loopback address is reachable from your network"
|
||||
local dpp
|
||||
for dpp in "${PUB_HOST_PORTS[@]}"; do
|
||||
if ! port_free "$dpp"; then
|
||||
chk WARN "host port $dpp is already in use -- this run would fail to bind it"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -d "$PWD/.git" ]]; then
|
||||
if [[ -n "$(git -C "$PWD" status --porcelain 2>/dev/null)" ]]; then
|
||||
chk WARN "the working tree has uncommitted changes: the sandbox can rewrite them (it has rw access)"
|
||||
|
||||
Reference in New Issue
Block a user