#!/bin/bash
# Add a domain to the egress firewall allowlist of a running dejima
# container, from outside it. The sandbox itself has no privilege to do this
# (node has no sudo/setuid path back to root - see this repo's README's
# Security notes section); if a sandboxed agent hits a blocked domain it
# actually needs, it should tell you the exact command to run here rather
# than try to work around the firewall.
#
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
#   dejima-allow crates.io                      # this running session only
#   dejima-allow --global crates.io             # persist for every project
#   dejima-allow --project crates.io            # persist for the project in $PWD
#   dejima-allow --project ~/myproj crates.io   # persist for one named project
#   dejima-allow dl.example.com files.example.org   # several, confirmed first
#
# --project and --global are approvals for future sessions and deliberately do
# not touch a running container: a scope flag means "record this", not "patch
# the thing running right now". Pass --container as well to do both.
set -euo pipefail

# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
    -h|--help)
        cat <<'DEJIMA_ALLOW_HELP'
dejima-allow - let a sandbox reach a domain the egress firewall blocks

Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
       Up to twenty. More than one is confirmed first, defaulting to no, which
       needs a terminal - so a script does them one at a time.

Scope, which decides who the grant is for and how long it lasts:
  (none)             the running container, right now, until it exits. Finds it
                     if exactly one is running. With several it offers them to
                     pick from on a terminal, by project and by the number the
                     dejima-attach picker gives each; anywhere else it lists
                     them and stops, a prompt being no use to a script.
  --container <id>   the same, for a named container. Use this when a session
                     told you its own id rather than letting anything guess.
  --project [path]   every future session in one project. Also writes the domain
                     to that project's .dejima/allowed-domains, so it is shared
                     with everyone working on it. Path defaults to $PWD.
  --global           every future session in every project on this host.

They combine, and --project/--global deliberately do NOT touch a running
session: they are approvals for future ones, so pass --container to do both.
Prefer --project to --global - a domain one project needs is that project's,
and a global grant widens every other project's firewall too.

With --container, each domain is verified reachable afterwards and a warning is
printed if it is not, so a typo or a blocked port does not look like success.
One that fails stops there naming what landed; a scope flag has them all by
then regardless.

See docs/configuration.md; docs/security.md explains what the allowlist bounds.
DEJIMA_ALLOW_HELP
        exit 0 ;;
esac

SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
# For dejima_class_for: the live add below changes a running container's
# permitted set, and which class a name belongs to is the container's rule, not
# this script's. Sourced rather than restated so the two cannot drift - the
# same reason firewall-up.sh reads this file.
# shellcheck source=../docker/fixed-domains.sh
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
# The palette, heading and prompt dejima-attach uses. Picking one session out
# of several is the same job here, and an operator holding one terminal should
# not meet two different answers to it.
# shellcheck source=dejima-tui-lib.sh
source "$SCRIPT_DIR/dejima-tui-lib.sh"

usage() {
    echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>..." >&2
    exit 1
}

# The most domains one command will take. A cap rather than no limit, because
# the confirmation below only works if the operator reads the list, and a list
# long enough to scroll is one nobody reads to the end of - which is where
# something would be hidden. Twenty is enough for the real case (a toolchain
# that fetches from several hosts) and short enough to take in at once.
readonly DEJIMA_ALLOW_MAX=20

# 1: at least one trailing positional, which is what tells parse_scope_args
# whether a token after --project is a path or a domain. It is a minimum, not
# an exact count - several domains may follow.
parse_scope_args 1 "$@" || usage
[ "${#dj_positional[@]}" -ge 1 ] || usage
domains=("${dj_positional[@]}")
global="$dj_global"
project=""
if [ "$dj_project_set" -eq 1 ]; then
    # parse_scope_args consumes a token after --project as the path whenever
    # enough positionals follow it, which with several domains is any of them.
    # A path that is not a directory and does read as a domain was the first
    # domain: `dejima-allow --project a.com b.com` means both of those, in the
    # project in $PWD. A directory wins, so a project whose name happens to
    # look like a hostname still works.
    if [ -n "$dj_project" ] && [ ! -d "$dj_project" ] \
        && [[ "$dj_project" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
        domains=("$dj_project" "${domains[@]}")
        dj_project=""
    fi
    project="$(cd "${dj_project:-$PWD}" && pwd)"
    disclose_project_symlink "$project"
fi
container="$dj_container"

# Repeats dropped, order kept. A list with the same name twice asks the
# operator to approve it twice and makes the count they are reading wrong.
_seen=""
_unique=()
for _d in "${domains[@]}"; do
    case "$_seen" in *"|${_d,,}|"*) continue ;; esac
    _seen="$_seen|${_d,,}|"
    _unique+=("$_d")
done
domains=("${_unique[@]}")

# What to call this request where it is described as a whole, rather than once
# per name. Anything outside the per-domain loops below has no $domain to use,
# and under set -u reaching for one is fatal rather than merely wrong - which is
# how the container picker's own prompt took this script down.
if [ "${#domains[@]}" -eq 1 ]; then
    domain_label="${domains[0]}"
else
    domain_label="${#domains[@]} domains"
fi

if [ "${#domains[@]}" -gt "$DEJIMA_ALLOW_MAX" ]; then
    echo "That is ${#domains[@]} domains, and this takes at most $DEJIMA_ALLOW_MAX at once." >&2
    echo "" >&2
    echo "The limit is there because the confirmation below is only worth anything if" >&2
    echo "you read the whole list. Split it up, and read each part." >&2
    exit 1
fi

# Every domain validated BEFORE any of them is shown or recorded, and the whole
# command refused if any fails. Two reasons beyond the obvious one.
#
# The list the operator confirms has to be the list that gets added: validating
# as we went would ask them to approve names, then add a prefix of them and
# fail. And the confirmation rests on the list being readable as printed - this
# pattern allows letters, digits, hyphens and dots and nothing else, so no
# entry can carry a carriage return or an escape sequence that rewrites the
# lines above it on the way past.
#
# It also has to be a well-formed hostname to be a valid dnsmasq
# server=/domain/... line (e.g. no embedded "/"), on top of ruling out shell
# metacharacters. A sandboxed agent can suggest this exact command for the
# operator to copy-paste (see sandbox-context.sh), so a prompt-injected agent
# handing back a malicious "domain" is a real path here, not a theoretical one.
#
# All of them reported, not the first: an operator fixing a typo in a list of
# ten should not have to run it ten times to find the second one.
_bad=()
for _d in "${domains[@]}"; do
    [[ "$_d" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]] || _bad+=("$_d")
done
if [ "${#_bad[@]}" -gt 0 ]; then
    if [ "${#_bad[@]}" -eq 1 ]; then
        echo "Not a valid domain: ${_bad[0]}" >&2
    else
        echo "Not valid domains:" >&2
        printf '  %s\n' "${_bad[@]}" >&2
    fi
    exit 1
fi

# One domain goes through as it always has. More than one is confirmed first,
# because a list is where something unwanted can ride along unread - and the
# agent that suggested the command is exactly what might have put it there.
#
# Default no, and the list on stdout one per line so nothing is off the right
# edge of a narrow terminal.
#
# No terminal means no confirmation is possible, so it refuses rather than
# assuming either answer. A script that wants several does them one at a time,
# which is the behaviour this had before and still has for one.
if [ "${#domains[@]}" -gt 1 ]; then
    echo "About to allow ${#domains[@]} domains:"
    printf '  %s\n' "${domains[@]}"
    echo ""
    _where=()
    [ -n "$project" ] && _where+=("every future session in $project")
    [ "$global" -eq 1 ] && _where+=("every future session in every project")
    [ -n "$container" ] && _where+=("the running session $container")
    [ "${#_where[@]}" -gt 0 ] || _where=("the running session")
    printf 'This grants them to: %s\n' "$(printf '%s; ' "${_where[@]}" | sed 's/; $//')"
    echo ""
    if [ ! -t 0 ]; then
        echo "Several domains at once needs a terminal to confirm on, and this is not one." >&2
        echo "Run them one at a time, which needs no confirmation." >&2
        exit 1
    fi
    printf 'Add all %s? [y/N] ' "${#domains[@]}"
    read -r _reply || _reply=""
    case "$_reply" in
        y|Y|yes|YES|Yes) ;;
        *) echo "Nothing was added."; exit 1 ;;
    esac
    echo ""
fi

# Recorded first, and unconditionally: --project/--global are the user's
# approval decision for future sessions, not a claim about this one. Gating
# either on a live container would break the documented workflow outright -
# approving dependencies requires leaving the sandbox, so the usual moment to
# run this is precisely when nothing is running. Gating on the reachability
# probe below would refuse any declared domain that legitimately doesn't serve
# HTTPS on 443. The two scopes are independent, not exclusive: both may be
# given together, and each persists to its own store.
if [ -n "$project" ]; then
    store="$(ensure_project_store "$project")"
    for domain in "${domains[@]}"; do
    approve_entry "$store" allowed-domains "$domain"
    echo "Added $domain to the approved domains for $project (applies to every future session in it)."
    # Printed only after the approval above, and guarded rather than left to
    # set -e: the approval is what grants, and it is already written by this
    # point. Letting a declare_entry failure abort here would exit before this
    # echo ever ran, leaving the operator thinking nothing happened when a
    # domain had, in fact, just been approved for every future session.
    if declare_entry "$project" allowed-domains "$domain"; then
        echo "Declared it in $project/.dejima/allowed-domains - commit that to share it."
    else
        echo "The approval above IS recorded and applies to every future session in it;" >&2
        echo "only the project's own .dejima declaration could not be written." >&2
    fi
    done
fi
if [ "$global" -eq 1 ]; then
    store="$(ensure_global_store)"
    for domain in "${domains[@]}"; do
        approve_entry "$store" allowed-domains "$domain"
        echo "Added $domain to the global approved domains (applies to every future session)."
    done
fi

# A scope flag means "record this approval", not "patch what is running now" -
# so a --project/--global invocation never goes near Docker, and stops
# reporting on containers the caller did not ask about. --container is the
# explicit opt-in to doing both.
#
# This is the only thing that decides. Deliberately not an environment
# variable as well: a firewall CLI with an environment-triggered no-op path
# is one an env var can silently disarm.
# A container list an operator can act on without asking the agent anything.
#
# The name alone is a run id, which is exactly what someone with three
# sessions open does not know. What they do know is which project each one is
# working on, and - where remote access is on - the number the dejima-attach
# picker gives it, because that is the list they are already reading.
#
# The project comes from the container's own working directory rather than
# from the session registry, so it is there for every session and not only a
# remote-enabled one. The picker number comes from `dejima-attach --list`,
# whose rows the picker numbers 1..N in the order they are printed; absent
# where remote access is off, which is a column missing rather than a failure.
#
# Reads only. Nothing here decides which container is acted on - that is the
# caller's --container, or the single running one.
# Fills CT_IDS, CT_NAMES, CT_PROJECTS and CT_ATTACH, in the order docker
# listed them, and measures the widest project and name for the renderers
# below. Padding to the widest value rather than to a guess is not cosmetic
# here: "dejima-sandbox-run-" plus twelve hex digits is 31 characters, so a
# fixed 28 ran every name straight into the project beside it and there was
# no telling where one ended.
CT_IDS=(); CT_NAMES=(); CT_PROJECTS=(); CT_ATTACH=()
CT_W_NAME=0; CT_W_PROJECT=0; CT_HAS_ATTACH=0
collect_containers() {
    local listing="$1" name id project row n=0 idx
    local -A attach_index=()
    while IFS=$'\t' read -r _ _ _ _ _ row; do
        [ -n "$row" ] || continue
        n=$((n + 1))
        attach_index["$row"]="$n"
    done < <(dejima-attach --list 2>/dev/null || true)
    while IFS=$'\t' read -r name id; do
        [ -n "$name" ] || continue
        project="$(docker inspect -f '{{.Config.WorkingDir}}' "$id" 2>/dev/null)" || project=""
        [ -n "$project" ] && project="$(basename "$project")"
        project="${project:-?}"
        idx="${attach_index[${id:0:12}]:-}"
        CT_IDS+=("$id"); CT_NAMES+=("$name"); CT_PROJECTS+=("$project")
        CT_ATTACH+=("$idx")
        [ -z "$idx" ] || CT_HAS_ATTACH=1
        [ "${#name}" -le "$CT_W_NAME" ] || CT_W_NAME="${#name}"
        [ "${#project}" -le "$CT_W_PROJECT" ] || CT_W_PROJECT="${#project}"
    done <<< "$listing"
}

# The non-interactive form: a list to read, with the flag that acts on a row.
# This is what a script gets, and what an agent is shown when it asks the
# operator to run one of these.
describe_containers() {
    local i
    for i in "${!CT_IDS[@]}"; do
        printf '  %-*s  %-*s  %s\n' \
            "$CT_W_PROJECT" "${CT_PROJECTS[$i]}" \
            "$CT_W_NAME" "${CT_NAMES[$i]}" \
            "${CT_ATTACH[$i]:+(dejima-attach ${CT_ATTACH[$i]})}"
    done
}

# The interactive form. Same rows, numbered, under dejima-attach's heading -
# an operator with several sessions open is choosing between the same things
# in both tools, and picking by project is the only way they can.
#
# Headed, where dejima-attach's rows are not: there the coloured state word
# says what each column is, and vertical space on a phone is the scarce
# thing. Here the columns are two names and a number, which say nothing about
# themselves.
pick_container() {
    local i
    tui_banner 'running sessions'
    # The attach column is absent, not blank, when no session has a number -
    # remote access off is the ordinary case, and a labelled empty column
    # reads as something that failed.
    if [ "$CT_HAS_ATTACH" -eq 1 ]; then
        printf '      %s%-*s  %-*s  %s%s\n' "$C_SUBTLE" \
            "$CT_W_PROJECT" "project" "$CT_W_NAME" "container" "attach" "$C_OFF"
    else
        printf '      %s%-*s  %s%s\n' "$C_SUBTLE" \
            "$CT_W_PROJECT" "project" "container" "$C_OFF"
    fi
    for i in "${!CT_IDS[@]}"; do
        printf '  %s%2d%s  %s%-*s%s  %s%-*s%s  %s%s%s\n' \
            "$C_WARN" "$((i + 1))" "$C_OFF" \
            "$C_BOLD$C_TEXT" "$CT_W_PROJECT" "${CT_PROJECTS[$i]}" "$C_OFF" \
            "$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
            "$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
    done
    tui_pick "${#CT_IDS[@]}" "allow $domain_label in"
    container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
}

session_wanted=1
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
    [ -n "$container" ] || session_wanted=0
fi
if [ "$session_wanted" -eq 1 ]; then
    if [ -z "$container" ]; then
        containers=$(docker ps --filter "label=com.docker.compose.project=dejima" \
            --filter "status=running" --format '{{.Names}}	{{.ID}}')
        count=$(echo "$containers" | grep -c . || true)
        if [ "$count" -eq 0 ]; then
            echo "No running dejima container found." >&2
            exit 1
        elif [ "$count" -gt 1 ]; then
            collect_containers "$containers"
            # A person at a terminal gets to choose; anything else gets the
            # list and the flag. Both ends are checked because the prompt is
            # useless without somewhere to draw it and something to read the
            # answer from, and half of either is how a script ends up hung on
            # a read nobody can answer.
            if [ -t 0 ] && [ -t 1 ]; then
                pick_container
            else
                echo "Multiple running dejima containers found; specify one with --container:" >&2
                echo "" >&2
                describe_containers >&2
                echo "" >&2
                echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
                echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
                exit 1
            fi
        fi
        # Only when nothing above chose one. This line used to be reachable
        # only in the single-container case, because the multi-container
        # branch exited - now that the picker returns from it, an
        # unconditional assignment here would silently discard the choice and
        # act on whichever session docker happened to list first.
        [ -n "$container" ] || container="${containers%%$'\t'*}"
    fi

    # The resolver and the packet filter are not in the session's container.
    # They are in the filter container that owns its network namespace, which
    # is what keeps a session from reaching either. So everything that CHANGES the
    # allowlist below runs there, and only the reachability check at the end
    # runs in the session's own container - because that is the container whose
    # reachability this command claims to have changed.
    #
    # Both are derived from whichever one the caller named, rather than
    # recorded anywhere: Docker already knows the pairing, because joining a
    # namespace is how the pair is built.
    #
    # Naming either half has to work, and not as a convenience. A session that
    # is asked for its own container id reads /etc/hostname, and Docker names a
    # container that JOINED another's network namespace after the container it
    # joined - so the id in a briefing, the one an agent hands its operator, is
    # the filter container's. Nothing about the pair is worth making an
    # operator hold in their head to type one command.
    mode="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$container" 2>/dev/null)" || mode=""
    case "$mode" in
        container:*)
            # A session container: it names its own filter.
            filter="${mode#container:}"
            ;;
        *)
            # Then it is a filter container, or not part of a pair at all.
            # `docker exec` takes a name or an id; NetworkMode reports the id,
            # so the search below compares ids.
            filter_id="$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null)" || filter_id=""
            session=""
            if [ -n "$filter_id" ]; then
                while IFS=' ' read -r _id _mode; do
                    [ "$_mode" = "container:$filter_id" ] || continue
                    session="$_id"
                    break
                done < <(docker ps -q 2>/dev/null | xargs -r docker inspect \
                    -f '{{.Id}} {{.HostConfig.NetworkMode}}' 2>/dev/null)
            fi
            if [ -z "$session" ]; then
                echo "$container is neither a running session nor the filter container of" >&2
                echo "one, so there is no allowlist to add this domain to." >&2
                echo "" >&2
                echo "A session's own id is in its startup context, or from 'cat /etc/hostname'" >&2
                echo "inside it - either half of the pair is accepted here. If the session has" >&2
                echo "already exited, record the domain for the next one instead:" >&2
                echo "" >&2
                echo "  dejima-allow --project ${domains[*]}" >&2
                exit 1
            fi
            filter="$container"
            container="$session"
            ;;
    esac

    # One command in the filter container does the whole change: it records the
    # entry, regenerates and restarts the restricted resolver so the name
    # resolves, waits for the answer, and confirms the address reached the set
    # its class uses. If any of that fails it puts the permitted set back the
    # way it was and exits non-zero, so there is no half-added name that
    # resolves and is then refused.
    #
    # The class is this script's to state and the container's to enforce - a
    # forge name added to the open class would be reachable on every port,
    # which is the read-only guarantee gone from a command whose whole output
    # says it granted one domain.
    #
    # --internal-ok for a name in [core] internal_domains, and for no other.
    #
    # The policy refuses an answer for a permitted name that points inside a
    # private network, because an answer decides what the allowlist contains
    # and comes from outside. An approval does not lift that: a project's
    # declared domains are reviewed with the project and expected to be public,
    # and exempting a public name is the pivot the check exists to stop. What
    # lifts it is the operator saying a specific name really is internal.
    #
    # Read from the same key a launch reads, so the two cannot disagree. They
    # did: this passed the exemption for any approval while firewall-up.sh
    # withheld it, so a live add succeeded and the next launch refused to start
    # on the same name - with the failed launch as the operator's first hint
    # that the two commands had different ideas.
    #
    # Each domain reaches the container as an argument rather than inside the
    # command string: they are validated above, and this keeps it that way if
    # that validation is ever loosened.
    #
    # Guarded on the file existing: config_get says so on stderr when it does
    # not, which is right for a launch and noise for this, where a host with no
    # configuration at all is an ordinary case and the answer is simply "not
    # listed". A config that exists and is malformed still reports itself.
    # One domain at a time, because each is its own change to the running
    # policy: the class it belongs to, whether the operator declared it
    # internal, and whether it then answers. A failure stops the loop rather
    # than carrying on, so what did and did not land is the order printed
    # above - and the names after it are still recorded under any scope flag
    # given, which happened before this and is not undone by a live add
    # failing.
    for domain in "${domains[@]}"; do
        internal=0
        if [ -f "$(config_path)" ]; then
            # `|| [ -n "$_d" ]`, because config_get prints its value with no
            # trailing newline - so a single-entry list is one line read never
            # returns, and the loop body would not run at all.
            # Case-folded, like the class decision below and dejima_mark_internal:
            # a live add that misses the exemption the operator configured leaves
            # the name without it until the next launch, which then refuses to
            # start on the same mismatch.
            while IFS= read -r _d || [ -n "$_d" ]; do
                [ "${_d,,}" = "${domain,,}" ] && internal=1
            done < <(config_get core internal_domains | tr ':' '\n')
        fi

        permit_args=(permit "$domain")
        [ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
        [ "$internal" -eq 1 ] && permit_args+=(--internal-ok)

        echo "Permitting $domain in $filter, this session's egress control..."
        if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
            echo "" >&2
            echo "The permitted set was left as it was - the reason is above." >&2
            if [ "$internal" -eq 0 ]; then
                echo "" >&2
                echo "If $domain resolves inside your own network, say so in the config and" >&2
                echo "relaunch - an answer pointing inside is refused for every name that is" >&2
                echo "not listed there, whether or not it is approved:" >&2
                echo "" >&2
                echo "  [core]" >&2
                echo "  internal_domains = $domain" >&2
                echo "" >&2
                echo "It still needs an approval to be reachable at all:" >&2
                echo "" >&2
                echo "  dejima-allow --global $domain" >&2
            fi
            exit 1
        fi

        echo "$domain allowed for the running session ($container)."

        # TCP-level truth check from inside the very container that was just
        # updated - this script's job is the firewall, so "TCP connect succeeded"
        # is the claim to verify. curl exit 6 (resolve failed), 7 (connect
        # refused/blocked) and 28 (connect or overall timeout) mean still
        # unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
        # the connection itself went through the firewall fine. --max-time bounds
        # the whole request, not just the connect phase - without it, a server that
        # accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
        # would hang this script indefinitely; curl maps that case to the same
        # exit 28 the case below already handles, so no new branch is needed. This
        # is a report on this run, not a gate on either scope's approval: both
        # --project and --global are already recorded above, deliberately - a
        # declared host may legitimately not answer on 443 (an internal service,
        # an SSH-only host, an apex that only serves a subdomain), and the natural
        # moment to add a global approval is precisely when nothing is running to
        # probe in the first place. The exit 2 below still means what it says:
        # the live patch to this session did not verify.
        echo "Verifying $domain is reachable from $container..."
        rc=0
        docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
        case "$rc" in
            6|7|28)
                echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
                echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
                echo "otherwise the allow did not take effect - re-check the container ID." >&2
                exit 2
                ;;
            *)
                echo "Verified: $domain is reachable from $container."
                ;;
        esac
    done
fi
