diff --git a/bin/.gitignore b/bin/.gitignore index f488d09f..15ee3abb 100644 --- a/bin/.gitignore +++ b/bin/.gitignore @@ -1,5 +1,8 @@ *-bin *-dist +# y-bin-dependency-download's temporaries, left only by a killed run +.download-* +.link-* # our executables are symlinked to real names buildctl diff --git a/bin/y-bin-dependency-download b/bin/y-bin-dependency-download index cc34846b..dd7a27f6 100755 --- a/bin/y-bin-dependency-download +++ b/bin/y-bin-dependency-download @@ -43,32 +43,39 @@ bin_link=$YBIN/$bin_name [ -z "$YSTACK_BIN_DOWNLOAD_CACHE" ] || bin_cache="$YSTACK_BIN_DOWNLOAD_CACHE/$bin_uniq" [ -z "$bin_cache" ] || [ ! -f "$bin_cache" ] || url="file://$bin_cache" echo "Missing $bin_name binary. Downloading a known version from $url ..." 1>&2 - case "$url" in - *.tar.gz) ;; - *.gz) bin_file=$bin_file.gz ;; - esac - curl -o $bin_file -L -f $url - if ! echo "$sha256 $bin_file" | $SHA256SUM -c - 1>&2; then $SHA256SUM $bin_file && rm $bin_file && exit 1; fi + # Download and unpack under a temporary name, renamed to $bin_file only when complete: + # an interrupted download (a caller's `| head` is enough) or one still running in another + # process must never be taken for the binary. + dl=$(mktemp "$YBIN/.download-$bin_uniq.XXXXXX") + trap 'rm -f "$dl" "$dl.gunzip"' EXIT + curl -o $dl -L -f $url + if ! echo "$sha256 $dl" | $SHA256SUM -c - 1>&2; then $SHA256SUM $dl && exit 1; fi if [ ! -z "$bin_tar_path" ]; then tmp=$(mktemp -d) - (cd $tmp; tar -xv${bin_tar_compression}f $bin_file --strip-components=$(echo $bin_tar_path | awk -F'/' '{print NF-1}') $bin_tar_extract) \ - || (cd $tmp; tar -tv${bin_tar_compression}f $bin_file; exit 1) - mv "$tmp/$(basename $bin_tar_path)" $bin_file + (cd $tmp; tar -xv${bin_tar_compression}f $dl --strip-components=$(echo $bin_tar_path | awk -F'/' '{print NF-1}') $bin_tar_extract) \ + || (cd $tmp; tar -tv${bin_tar_compression}f $dl; exit 1) + mv "$tmp/$(basename $bin_tar_path)" $dl + rm -rf "$tmp" fi if [ ! -z "$bin_zip_path" ]; then tmp=$(mktemp -d) - (cd $tmp; unzip $bin_file) - mv "$tmp/$bin_zip_path" $bin_file + (cd $tmp; unzip $dl) + mv "$tmp/$bin_zip_path" $dl + rm -rf "$tmp" fi case "$url" in *.tar.gz) ;; - *.gz) gunzip $bin_file && bin_file=$(echo $bin_file | sed 's/\.gz$//g') ;; + *.gz) gzip -dc $dl > $dl.gunzip && mv $dl.gunzip $dl ;; esac + chmod 755 $dl + mv -f $dl $bin_file + trap - EXIT [ -z "$bin_cache" ] || [ -f "$bin_cache" ] || cp -v $bin_file $bin_cache 1>&2 - chmod a+x $bin_file } if [ "$(readlink $bin_link)" != "$bin_file" ]; then - [ -L $bin_link ] && rm $bin_link - ln -s $bin_file $bin_link + [ ! -e $bin_link ] || [ -L $bin_link ] || { echo "Not a symlink, not replacing: $bin_link" >&2; exit 1; } + # replaced by rename, so a concurrent run never sees the link missing + ln -s $bin_file $YBIN/.link-$bin_name.$$ + mv -f $YBIN/.link-$bin_name.$$ $bin_link fi diff --git a/bin/y-cluster-sudoers b/bin/y-cluster-sudoers index 68a1393e..cd0a6819 100755 --- a/bin/y-cluster-sudoers +++ b/bin/y-cluster-sudoers @@ -2,25 +2,37 @@ [ -z "$DEBUG" ] || set -x set -eo pipefail -YSTACK_HOME="$(cd "$(dirname "$0")/.." && pwd)" +YSTACK_HOME="$(cd "$(dirname "$0")/.." && pwd -P)" YBIN="$YSTACK_HOME/bin" - SUDOERS_FILE="/etc/sudoers.d/ystack-cluster" +# Root-owned snapshots of the helpers: a NOPASSWD rule for a file its grantee can edit +# would be root for the grantee, so the rules name these copies only +PRIVILEGED_DIR="/usr/local/libexec/ystack" +HELPERS=(y-etc-hosts-write y-localhost) usage() { cat >&2 <&2; exit 1 ;; + *) break ;; + esac +done USERS=("$@") for u in "${USERS[@]}"; do - case "$u" in -*) echo "ERROR: invalid username '$u' (did you mean to put --write first?)" >&2; exit 1 ;; esac + case "$u" in -*) echo "ERROR: options go before usernames, got '$u'" >&2; exit 1 ;; esac + id -u "$u" >/dev/null 2>&1 || { echo "ERROR: no such user '$u'" >&2; exit 1; } done [ ${#USERS[@]} -eq 0 ] && USERS=("${SUDO_USER:-$USER}") rules() { echo "# ystack development cluster automation" echo "# Generated by y-cluster-sudoers for: ${USERS[*]}" - echo "# YSTACK_HOME=$YSTACK_HOME" + echo "# Helpers installed from $YSTACK_HOME to $PRIVILEGED_DIR, see $PRIVILEGED_DIR/SOURCE" for u in "${USERS[@]}"; do cat </dev/null; then - echo "Writing sudoers rules requires root. Re-running with sudo ..." - exec sudo "$0" --write "${USERS[@]}" - else - echo "Can't sudo as $USER. A user with sudo rights can run for example:" >&2 - echo " sudo -u $USER $0 ${USERS[*]} | sudo tee $SUDOERS_FILE" >&2 - exit 1 - fi - fi - TMPFILE=$(mktemp) - rules > "$TMPFILE" - if visudo -cf "$TMPFILE"; then - cp "$TMPFILE" "$SUDOERS_FILE" - chmod 0440 "$SUDOERS_FILE" - rm "$TMPFILE" - echo "Wrote $SUDOERS_FILE" - sudo_rs_check - else - rm "$TMPFILE" - echo "ERROR: Generated sudoers rules failed validation" >&2 - exit 1 - fi -else +if [ "$MODE" = "show" ]; then rules + echo "# --write would install: ${HELPERS[*]/#/$YBIN/} -> $PRIVILEGED_DIR/ (root:0 0755)" + GRANTEE_CONTROLLED=$(grantee_controlled_sources) + [ -z "$GRANTEE_CONTROLLED" ] || { echo "# --write would refuse, a grantee controls the source:"; echo "$GRANTEE_CONTROLLED" | sed 's/^/# /'; } sudo_rs_check + exit 0 +fi + +if [ "$(id -u)" -ne 0 ]; then + echo "Writing sudoers rules requires root. Run with sudo, from a checkout root can read:" >&2 + echo " sudo $0 --write ${USERS[*]}" >&2 + exit 1 +fi + +GRANTEE_CONTROLLED=$(grantee_controlled_sources) +if [ -n "$GRANTEE_CONTROLLED" ] && [ "$ALLOW_GRANTEE_SOURCE" != "true" ]; then + echo "ERROR: refusing to install root-run helpers from a checkout a grantee controls:" >&2 + echo "$GRANTEE_CONTROLLED" >&2 + echo "Run from your own ystack checkout, or pass --allow-grantee-source after reviewing it." >&2 + exit 1 fi + +TMPFILE=$(mktemp) +trap 'rm -f "$TMPFILE"' EXIT +rules > "$TMPFILE" +if ! visudo -cf "$TMPFILE"; then + echo "ERROR: Generated sudoers rules failed validation" >&2 + exit 1 +fi + +install -d -o 0 -g 0 -m 0755 "$PRIVILEGED_DIR" +for h in "${HELPERS[@]}"; do + install -o 0 -g 0 -m 0755 "$YBIN/$h" "$PRIVILEGED_DIR/$h" +done +# No git as root in a checkout root does not own: its config can run commands +{ + echo "# Installed by y-cluster-sudoers --write for: ${USERS[*]}" + echo "installed=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "source=$YSTACK_HOME" + (cd "$PRIVILEGED_DIR" && for h in "${HELPERS[@]}"; do shasum -a 256 "$h" 2>/dev/null || sha256sum "$h"; done) +} > "$PRIVILEGED_DIR/SOURCE.tmp" +chmod 0644 "$PRIVILEGED_DIR/SOURCE.tmp" +mv "$PRIVILEGED_DIR/SOURCE.tmp" "$PRIVILEGED_DIR/SOURCE" + +install -o 0 -g 0 -m 0440 "$TMPFILE" "$SUDOERS_FILE" +echo "Installed ${HELPERS[*]} to $PRIVILEGED_DIR, wrote $SUDOERS_FILE" +sudo_rs_check diff --git a/bin/y-etc-hosts-write b/bin/y-etc-hosts-write index 7ffa7622..1f0c8916 100755 --- a/bin/y-etc-hosts-write +++ b/bin/y-etc-hosts-write @@ -13,6 +13,16 @@ path the way a rule for a flag-taking binary can. Refuses input that is empty or has no 127.0.0.1 entry, and swaps the file in with rename(2) so a reader never sees a half-written hosts file. +It runs as root for users who are not root otherwise, so it only lets them manage +the block k8s-ingress-hosts owns: everything outside the lines + # generated using k8s-ingress-hosts start # + # generated using k8s-ingress-hosts end # +must stay as it is in the current file, there is at most one such block, and each +entry in it maps plain hostnames to a loopback or private IPv4 address (127/8, +10/8, 172.16/12, 192.168/16), optionally followed by a # comment. A name that has +an entry outside the block keeps that address. Input is limited to 1 MiB. Other +edits of /etc/hosts are for root to make. + Environment: Y_ETC_HOSTS Target path (default: /etc/hosts) @@ -47,7 +57,13 @@ fi TMP=$(mktemp "$TARGET_DIR/.hosts.XXXXXX") trap 'rm -f "$TMP"' EXIT -cat > "$TMP" +# Root writes this into the target's filesystem, so the input is bounded +MAX_BYTES=1048576 +head -c $((MAX_BYTES + 1)) > "$TMP" +if [ "$(wc -c < "$TMP")" -gt "$MAX_BYTES" ]; then + echo "ERROR: refusing input over $MAX_BYTES bytes" >&2 + exit 2 +fi if [ ! -s "$TMP" ]; then echo "ERROR: refusing to write an empty $TARGET" >&2 @@ -58,6 +74,61 @@ if ! grep -qE '^[[:space:]]*127\.0\.0\.1[[:space:]]' "$TMP"; then exit 2 fi +BLOCK_START='# generated using k8s-ingress-hosts start #' +BLOCK_END='# generated using k8s-ingress-hosts end #' + +# Lines outside the managed block, markers excluded +outside_block() { + awk -v s="$BLOCK_START" -v e="$BLOCK_END" '$0 == s {inb=1; next} $0 == e {inb=0; next} !inb' "$1" +} + +if [ "$(grep -cxF "$BLOCK_START" "$TMP")" -gt 1 ] || [ "$(grep -cxF "$BLOCK_START" "$TMP")" != "$(grep -cxF "$BLOCK_END" "$TMP")" ]; then + echo "ERROR: refusing input without exactly zero or one k8s-ingress-hosts block" >&2 + exit 2 +fi + +if [ -f "$TARGET" ] && ! cmp -s <(outside_block "$TARGET") <(outside_block "$TMP"); then + echo "ERROR: refusing input that changes $TARGET outside the k8s-ingress-hosts block:" >&2 + diff <(outside_block "$TARGET") <(outside_block "$TMP") >&2 || true # y-script-lint:disable=or-true # diff exits 1 when it has output, which is the case being reported + exit 2 +fi + +# Two passes over the input: the first records the addresses of names outside the block, +# so a block entry cannot give such a name another address (the block may come first) +BAD_ENTRIES=$(awk -v s="$BLOCK_START" -v e="$BLOCK_END" ' + function octet(x) { return x ~ /^[0-9]+$/ && x + 0 <= 255 } + function allowed(ip, o) { + if (split(ip, o, ".") != 4 || !octet(o[1]) || !octet(o[2]) || !octet(o[3]) || !octet(o[4])) return 0 + return o[1] == 127 || o[1] == 10 || (o[1] == 172 && o[2] >= 16 && o[2] <= 31) || (o[1] == 192 && o[2] == 168) + } + NR == FNR { + if ($0 == s) { inb = 1 } else if ($0 == e) { inb = 0 } + else if (!inb && $0 !~ /^[[:space:]]*(#|$)/) { + for (i = 2; i <= NF && $i !~ /^#/; i++) if (!(tolower($i) in outside)) outside[tolower($i)] = $1 + } + next + } + FNR == 1 {inb=0} + $0 == s {inb=1; next} + $0 == e {inb=0; next} + inb && $0 !~ /^[[:space:]]*(#|$)/ { + ok = allowed($1) + names = 0 + # k8s-ingress-hosts ends each entry with a comment naming its route + for (i = 2; i <= NF && $i !~ /^#/; i++) { + names++ + if ($i !~ /^[A-Za-z0-9]([-A-Za-z0-9.]*[A-Za-z0-9])?$/) ok = 0 + # Host lookups ignore case, so DB.INTERNAL is db.internal + if ((tolower($i) in outside) && outside[tolower($i)] != $1) ok = 0 + } + if (!ok || names == 0) print + }' "$TMP" "$TMP") +if [ -n "$BAD_ENTRIES" ]; then + echo "ERROR: refusing k8s-ingress-hosts entries that are not a loopback or private IPv4 address followed by hostnames, or that give a name from outside the block another address:" >&2 + echo "$BAD_ENTRIES" >&2 + exit 2 +fi + if cmp -s "$TMP" "$TARGET"; then echo "# $TARGET already up to date" exit 0 diff --git a/bin/y-k8s-ingress-hosts b/bin/y-k8s-ingress-hosts index f720551f..a92bcc37 100755 --- a/bin/y-k8s-ingress-hosts +++ b/bin/y-k8s-ingress-hosts @@ -210,7 +210,20 @@ if cmp -s "$NEW_HOSTS" "$HOSTS_FILE"; then fi if [ "$HOSTS_FILE" = "/etc/hosts" ] && [ "$(id -u)" -ne 0 ]; then - sudo "$YBIN/y-etc-hosts-write" < "$NEW_HOSTS" + # The root-owned copy y-cluster-sudoers --write installs is what its NOPASSWD rule names; + # the checkout's own copy only works with a sudo password (or an old, broader rule) + WRITER=/usr/local/libexec/ystack/y-etc-hosts-write + case "$(uname -s)" in + Darwin) WRITER_PERM=$(stat -f '%u %Lp' "$WRITER" 2>/dev/null) || WRITER_PERM= ;; + *) WRITER_PERM=$(stat -c '%u %a' "$WRITER" 2>/dev/null) || WRITER_PERM= ;; + esac + if [[ "$WRITER_PERM" =~ ^0\ [0-7]?[0-7][0145][0145]$ ]]; then + cmp -s "$WRITER" "$YBIN/y-etc-hosts-write" \ + || echo "# $WRITER differs from $YBIN/y-etc-hosts-write, rerun y-cluster-sudoers --write to update it" >&2 + else + WRITER="$YBIN/y-etc-hosts-write" + fi + sudo "$WRITER" < "$NEW_HOSTS" else Y_ETC_HOSTS="$HOSTS_FILE" "$YBIN/y-etc-hosts-write" < "$NEW_HOSTS" fi diff --git a/bin/y-localhost b/bin/y-localhost index 26c599a4..38da0815 100755 --- a/bin/y-localhost +++ b/bin/y-localhost @@ -4,33 +4,62 @@ set -eo pipefail [ "$1" = "help" ] && echo ' Tries to bring up a loopback interface so port bindings can be done without conflicts + +Only 127.0.0.0/8 addresses: it runs as root through a NOPASSWD rule, and a public +address on lo would take over the host'"'"'s traffic to it. Escalates through the +root-owned copy that y-cluster-sudoers --write installs, when there is one. ' && exit 0 +PRIVILEGED_DIR=/usr/local/libexec/ystack + hostname=$1 [ -z "$hostname" ] && echo "First arg must be a hostname" && exit 1 +[[ "$hostname" =~ ^[A-Za-z0-9]([-A-Za-z0-9.]*[A-Za-z0-9])?$ ]] || { echo "Not a hostname: '$hostname'" >&2; exit 1; } + +# Root-owned and not writable by others, so a NOPASSWD rule for it grants only what it does +privileged_self() { + local f="$PRIVILEGED_DIR/y-localhost" perm + [ -f "$f" ] || return 1 + case "$(uname -s)" in + Darwin) perm=$(stat -f '%u %Lp' "$f") ;; + *) perm=$(stat -c '%u %a' "$f") ;; + esac + [[ "$perm" =~ ^0\ [0-7]?[0-7][0145][0145]$ ]] && echo "$f" +} +escalate() { + local self + if self=$(privileged_self); then exec sudo "$self" "$@"; fi + exec sudo "$0" "$@" +} platform="$(uname -s)" -ip=$(cat /etc/hosts | grep -e "[[:blank:]]\<$hostname\>" | awk '{ print $1 }') +ip=$(awk -v h="$hostname" '$1 !~ /^#/ { for (i = 2; i <= NF; i++) if ($i == h) { print $1; exit } }' /etc/hosts) [ -z "$ip" ] && >&2 echo "Host '$hostname' not found in /etc/hosts" && exit 1 [ "$2" = "show" ] && echo -n "$ip" && exit 0 -num=$(echo -n $ip | cut -d '.' -f 4) +if ! [[ "$ip" =~ ^127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] \ + || [ "${BASH_REMATCH[1]}" -gt 255 ] || [ "${BASH_REMATCH[2]}" -gt 255 ] || [ "${BASH_REMATCH[3]}" -gt 255 ]; then + echo "Refusing $ip for $hostname: loopback aliases must be in 127.0.0.0/8" >&2 + exit 1 +fi + +num=$(echo -n "$ip" | cut -d '.' -f 4) case "$platform" in Darwin) - ifconfig lo0 | grep $ip > /dev/null && exit 0 + ifconfig lo0 | grep -F "$ip" > /dev/null && exit 0 echo "Loopback alias for $ip $hostname not found. Will try to create ..." - [ $(id -u) -eq 0 ] || exec sudo $0 "$@" - ifconfig lo0 alias $ip up + [ "$(id -u)" -eq 0 ] || escalate "$@" + ifconfig lo0 alias "$ip" up ;; Linux) - if [ -z "$(ip addr show dev lo label lo:$num)" ]; then + if [ -z "$(ip addr show dev lo label "lo:$num")" ]; then echo "Adding missing interface lo:$num ..." - [ $(id -u) -eq 0 ] || exec sudo $0 "$@" - ip addr add $ip/32 dev lo label lo:$num + [ "$(id -u)" -eq 0 ] || escalate "$@" + ip addr add "$ip/32" dev lo label "lo:$num" fi ;; *) diff --git a/buckety-controller/buckety-controller.yaml b/buckety-controller/buckety-controller.yaml index d03b496e..13065442 100644 --- a/buckety-controller/buckety-controller.yaml +++ b/buckety-controller/buckety-controller.yaml @@ -67,10 +67,8 @@ backends: # # Deliberately NOT named site-userdata-blobs, which is what checkit's g2 # backend is called: that name belongs to per-site end-user blobs, and this -# bucket holds build artifacts. When cluster-local starts hosting sites, the -# right move is to ADD a site-userdata-blobs entry pointing at this same -# versitygw - at which point checkit's per-site CRs become byte-identical here, -# which is the whole point of the naming contract. +# bucket holds build artifacts; site-userdata-blobs below points at the same +# versitygw so checkit's per-site CRs are byte-identical here and on g2. - name: builds-registry-blobs driver: s3 config: @@ -84,3 +82,15 @@ backends: # deployment-patch.yaml). accessKeyID: ${VERSITYGW_ROOT_ACCESSKEY} secretAccessKey: ${VERSITYGW_ROOT_SECRETKEY} + +# Per-site end-user blobs: checkit's blobs-v3/site-apply Buckety CR asks for this +# backend by name, one bucket per site, on g2 a GCS bucket, here versitygw. +- name: site-userdata-blobs + driver: s3 + config: + implementation: versitygw + endpoint: http://y-s3-api.blobs.svc.cluster.local:9000 + region: us-east-1 + forcePathStyle: true + accessKeyID: ${VERSITYGW_ROOT_ACCESSKEY} + secretAccessKey: ${VERSITYGW_ROOT_SECRETKEY}