| added scripts/host-repo.sh |
| + | #!/bin/bash |
| + | # host-repo.sh — publish a git checkout as a bare repo on upspin, ready to be |
| + | # browsed, cloned, and pushed through gitweb (https://git.cosmocr.at). |
| + | # |
| + | # Usage: |
| + | # scripts/host-repo.sh [--public | --access FILE] [--config FILE] \ |
| + | # [--owner-config FILE] <checkout-path> <upspin-path> |
| + | # |
| + | # <checkout-path> an existing git working copy (committed history is |
| + | # published; the working tree and remotes are not) |
| + | # <upspin-path> destination, e.g. rpg137@gmail.com/myrepo.git |
| + | # --public grant read,list to all (anonymous browse + clone); |
| + | # default is private: only the owner and the service |
| + | # --access FILE use FILE as the repo's Access file instead of the |
| + | # generated one (mutually exclusive with --public) |
| + | # --config FILE upspin config to use (default $HOME/upspin/config) |
| + | # --owner-config FILE |
| + | # config of the destination tree's OWNER, used only to put |
| + | # the Access file. Needed when hosting into another user's |
| + | # tree: upspin lets only the owner write Access files, while |
| + | # the content upload runs as the (granted) --config user. |
| + | # |
| + | # What it does, and why: |
| + | # - Makes a fresh `git clone --bare` of the checkout (single packfile + |
| + | # packed-refs — the fewest upspin files), drops hooks/description and the |
| + | # origin remote (which would leak the local path), and sanity-checks HEAD. |
| + | # - Puts the Access file FIRST, then uploads the repo content. With ee |
| + | # packing, each file's decryption key is wrapped for the readers granted |
| + | # by the governing Access file *at write time* — so the grants (the |
| + | # service, and `all` for --public) must be in place before the upload, or |
| + | # every file would need an `upspin share -fix` afterwards to be readable. |
| + | # - Uploads HEAD last: gitweb treats a directory as a repo once HEAD and |
| + | # objects/ both exist, so holding HEAD back keeps the repo from being |
| + | # served half-copied. |
| + | # - The Access file grants `*` (all rights) to the owner and to |
| + | # git@cosmocr.at: the service reads the repo on behalf of web/git clients, |
| + | # and `git push` write-back happens as git@cosmocr.at, so it needs |
| + | # create/write/delete. Who may *push* through gitweb is whoever holds the |
| + | # upspin `write` right in this Access file — add collaborators there |
| + | # (scripts/update-access.sh edits it and re-shares in one step). |
| + | # |
| + | # Requires: git, upspin (with the destination tree owned by the configured |
| + | # upspin user — only the owner may put Access files). |
| + | set -euo pipefail |
| + | |
| + | die() { echo "host-repo.sh: $*" >&2; exit 1; } |
| + | |
| + | PUBLIC=0 |
| + | CONFIG="${HOME}/upspin/config" |
| + | OWNERCONFIG="" |
| + | ACCESSFILE="" |
| + | ARGS=() |
| + | while [ $# -gt 0 ]; do |
| + | case "$1" in |
| + | --public) PUBLIC=1 ;; |
| + | --access) shift; ACCESSFILE="${1:?--access needs a file}" ;; |
| + | --config) shift; CONFIG="${1:?--config needs a file}" ;; |
| + | --owner-config) shift; OWNERCONFIG="${1:?--owner-config needs a file}" ;; |
| + | -h|--help) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; |
| + | -*) die "unknown flag $1" ;; |
| + | *) ARGS+=("$1") ;; |
| + | esac |
| + | shift |
| + | done |
| + | [ "${#ARGS[@]}" -eq 2 ] || die "usage: host-repo.sh [--public | --access FILE] [--config FILE] [--owner-config FILE] <checkout-path> <upspin-path>" |
| + | CHECKOUT="${ARGS[0]}" |
| + | DEST="${ARGS[1]%/}" |
| + | [ -z "$ACCESSFILE" ] || [ "$PUBLIC" = 0 ] || die "--access and --public are mutually exclusive (put read/list: all in the file)" |
| + | [ -z "$ACCESSFILE" ] || [ -s "$ACCESSFILE" ] || die "--access file missing or empty: $ACCESSFILE" |
| + | |
| + | command -v git >/dev/null || die "git not found" |
| + | command -v upspin >/dev/null || die "upspin not found" |
| + | [ -f "$CONFIG" ] || die "upspin config not found: $CONFIG" |
| + | |
| + | # --- validate the source checkout --- |
| + | git -C "$CHECKOUT" rev-parse --git-dir >/dev/null 2>&1 || die "$CHECKOUT is not a git checkout" |
| + | git -C "$CHECKOUT" rev-parse -q --verify HEAD >/dev/null || die "$CHECKOUT has no commits" |
| + | |
| + | # --- validate the destination path --- |
| + | OWNER="${DEST%%/*}" |
| + | case "$OWNER" in |
| + | *@*.*) ;; |
| + | *) die "destination must start with an upspin user (user@domain/...): $DEST" ;; |
| + | esac |
| + | [ "$DEST" != "$OWNER" ] || die "destination must include a repo path under the user root" |
| + | case "$DEST" in |
| + | *.git) ;; |
| + | *) echo "note: destination does not end in .git — works, but the convention helps clients" >&2 ;; |
| + | esac |
| + | |
| + | ME=$(sed -n 's/^username: *//p' "$CONFIG" | head -1) |
| + | if [ -n "$OWNERCONFIG" ]; then |
| + | [ -f "$OWNERCONFIG" ] || die "owner config not found: $OWNERCONFIG" |
| + | OC_USER=$(sed -n 's/^username: *//p' "$OWNERCONFIG" | head -1) |
| + | [ "$OC_USER" = "$OWNER" ] || die "--owner-config user is $OC_USER but the destination tree is owned by $OWNER" |
| + | elif [ "$ME" != "$OWNER" ]; then |
| + | echo "warning: destination tree is owned by $OWNER but the config user is $ME;" >&2 |
| + | echo " the Access put will fail unless run as the owner (see --owner-config)" >&2 |
| + | fi |
| + | |
| + | if upspin -config="$CONFIG" ls "$DEST" >/dev/null 2>&1; then |
| + | die "$DEST already exists — refusing to overwrite" |
| + | fi |
| + | |
| + | # --- stage a clean bare repo --- |
| + | TMP=$(mktemp -d) |
| + | trap 'rm -rf "$TMP"' EXIT |
| + | BASE=$(basename "$DEST") |
| + | STAGE="$TMP/$BASE" # cp -R below copies the dir itself, so its name is final |
| + | |
| + | git clone --quiet --bare "$CHECKOUT" "$STAGE" |
| + | git -C "$STAGE" config --remove-section remote.origin 2>/dev/null || true |
| + | git -C "$STAGE" pack-refs --all |
| + | rm -rf "$STAGE/hooks" "$STAGE/description" |
| + | |
| + | # HEAD must be a symref to a branch that exists, or clients see an empty repo. |
| + | HEADREF=$(git -C "$STAGE" symbolic-ref -q HEAD || true) |
| + | if [ -z "$HEADREF" ] || ! git -C "$STAGE" show-ref -q --verify "$HEADREF"; then |
| + | FIRST=$(git -C "$STAGE" for-each-ref --format='%(refname)' refs/heads | head -1) |
| + | [ -n "$FIRST" ] || die "staged repo has no branches" |
| + | git -C "$STAGE" symbolic-ref HEAD "$FIRST" |
| + | echo "note: HEAD pointed at a missing branch; reset to $FIRST" >&2 |
| + | fi |
| + | |
| + | # --- upspin with config --- |
| + | UP() { upspin -config="$CONFIG" "$@"; } |
| + | |
| + | # --- upload: parents, Access first, content, HEAD last --- |
| + | if [ "$(dirname "$DEST")" != "$OWNER" ]; then |
| + | # Create intermediate directories one level at a time (no mkdir -p). |
| + | REL=$(dirname "${DEST#"$OWNER"/}") |
| + | P="$OWNER" |
| + | IFS='/' read -ra SEGS <<< "$REL" |
| + | for seg in "${SEGS[@]}"; do |
| + | P="$P/$seg" |
| + | UP mkdir "$P" 2>/dev/null || true |
| + | done |
| + | fi |
| + | UP mkdir "$DEST" |
| + | |
| + | # Access before content, so ee-packed uploads wrap keys for these readers. |
| + | # Only the tree owner may put Access files, hence the --owner-config escape. |
| + | ACCESS="$TMP/Access" |
| + | if [ -n "$ACCESSFILE" ]; then |
| + | cp "$ACCESSFILE" "$ACCESS" |
| + | else |
| + | { |
| + | echo "*: $OWNER, git@cosmocr.at" |
| + | if [ "$PUBLIC" = 1 ]; then |
| + | echo "read: all" |
| + | echo "list: all" |
| + | fi |
| + | } > "$ACCESS" |
| + | fi |
| + | if [ -n "$OWNERCONFIG" ]; then |
| + | upspin -config="$OWNERCONFIG" cp "$ACCESS" "$DEST/Access" |
| + | else |
| + | UP cp "$ACCESS" "$DEST/Access" |
| + | fi |
| + | |
| + | echo "==> uploading $(du -sh "$STAGE" | cut -f1) to $DEST" |
| + | # Everything except HEAD, then HEAD: the repo only becomes servable once |
| + | # HEAD lands. `upspin cp -R dir dst/` copies dir *into* dst by basename. |
| + | for entry in "$STAGE"/*; do |
| + | name=$(basename "$entry") |
| + | [ "$name" = "HEAD" ] && continue |
| + | if [ -d "$entry" ]; then |
| + | UP cp -R "$entry" "$DEST/" |
| + | else |
| + | UP cp "$entry" "$DEST/" |
| + | fi |
| + | done |
| + | UP cp "$STAGE/HEAD" "$DEST/" |
| + | |
| + | # --- verify --- |
| + | UP ls "$DEST/HEAD" >/dev/null || die "verify failed: $DEST/HEAD not readable" |
| + | URL="https://git.cosmocr.at/$DEST" |
| + | echo "==> hosted at $DEST" |
| + | if grep -qE '^\s*read\s*[:,].*\ball\b' "$ACCESS"; then |
| + | echo " browse/clone (anonymous): $URL" |
| + | else |
| + | echo " private: browse/clone/push need credentials (vault SSO via" |
| + | echo " git-credential-cosmocr, or a PAT in $OWNER/gitweb/tokens)" |
| + | echo " clone: git clone $URL" |
| + | fi |
| + | echo " push access: users with 'write' in $DEST/Access" |
| added scripts/update-access.sh |
| + | #!/bin/bash |
| + | # update-access.sh — edit the Access file of an upspin directory (typically a |
| + | # gitweb-hosted repo) and re-share, so the new grants actually take effect on |
| + | # already-uploaded files. |
| + | # |
| + | # Usage: |
| + | # scripts/update-access.sh [flags] <upspin-dir> |
| + | # |
| + | # <upspin-dir> directory whose Access file to update, |
| + | # e.g. rpg137@gmail.com/myrepo.git |
| + | # --file FILE replace the Access file with FILE (non-interactive); |
| + | # default is to open the current one in $EDITOR |
| + | # --fix-only leave the Access file alone, only re-share (use after an |
| + | # Access change made by other means) |
| + | # --force rewrap keys regardless of current state; needed for the |
| + | # change to stick after REMOVING readers |
| + | # --config FILE upspin config to use (default $HOME/upspin/config) |
| + | # |
| + | # Why the re-share step: with ee packing, each file's decryption key is |
| + | # wrapped per reader at write time. Editing Access changes who *should* read, |
| + | # but not the wrapped keys — `upspin share -fix -r` reconciles them (adding |
| + | # the cleartext "all" entry for read:all, and per-user wraps for new grants). |
| + | # |
| + | # Revocation caveat: removing a reader (even with --force) only removes their |
| + | # wrapped key going forward; they may have already saved file keys or content. |
| + | # For real secrecy after a leak, rotate keys / re-encrypt (see `upspin rotate` |
| + | # and `upspin share -help`). |
| + | set -euo pipefail |
| + | |
| + | die() { echo "update-access.sh: $*" >&2; exit 1; } |
| + | |
| + | CONFIG="${HOME}/upspin/config" |
| + | FILE="" |
| + | FIXONLY=0 |
| + | FORCE=0 |
| + | ARGS=() |
| + | while [ $# -gt 0 ]; do |
| + | case "$1" in |
| + | --file) shift; FILE="${1:?--file needs a file}" ;; |
| + | --fix-only) FIXONLY=1 ;; |
| + | --force) FORCE=1 ;; |
| + | --config) shift; CONFIG="${1:?--config needs a file}" ;; |
| + | -h|--help) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; |
| + | -*) die "unknown flag $1" ;; |
| + | *) ARGS+=("$1") ;; |
| + | esac |
| + | shift |
| + | done |
| + | [ "${#ARGS[@]}" -eq 1 ] || die "usage: update-access.sh [--file FILE | --fix-only] [--force] [--config FILE] <upspin-dir>" |
| + | DIR="${ARGS[0]%/}" |
| + | [ -z "$FILE" ] || [ "$FIXONLY" = 0 ] || die "--file and --fix-only are mutually exclusive" |
| + | |
| + | command -v upspin >/dev/null || die "upspin not found" |
| + | [ -f "$CONFIG" ] || die "upspin config not found: $CONFIG" |
| + | case "${DIR%%/*}" in |
| + | *@*.*) ;; |
| + | *) die "path must start with an upspin user (user@domain/...): $DIR" ;; |
| + | esac |
| + | UP() { upspin -config="$CONFIG" "$@"; } |
| + | |
| + | UP ls "$DIR" >/dev/null 2>&1 || die "$DIR does not exist or is not readable" |
| + | ACCESSPATH="$DIR/Access" |
| + | |
| + | if [ "$FIXONLY" = 0 ]; then |
| + | TMP=$(mktemp -d) |
| + | trap 'rm -rf "$TMP"' EXIT |
| + | NEW="$TMP/Access" |
| + | if [ -n "$FILE" ]; then |
| + | cp "$FILE" "$NEW" |
| + | else |
| + | # Start from the current Access file (empty if none exists yet — note |
| + | # that without one, access is governed by the nearest ancestor's). |
| + | UP get "$ACCESSPATH" > "$NEW" 2>/dev/null || : > "$NEW" |
| + | [ -t 0 ] || die "stdin is not a terminal; use --file for non-interactive updates" |
| + | OLDSUM=$(sha256sum "$NEW" | cut -d' ' -f1) |
| + | ${EDITOR:-vi} "$NEW" |
| + | if [ "$(sha256sum "$NEW" | cut -d' ' -f1)" = "$OLDSUM" ]; then |
| + | echo "Access unchanged; re-sharing anyway" |
| + | fi |
| + | fi |
| + | [ -s "$NEW" ] || die "refusing to install an empty Access file (rm $ACCESSPATH to fall back to inherited access)" |
| + | UP cp "$NEW" "$ACCESSPATH" |
| + | echo "==> installed $ACCESSPATH:" |
| + | sed 's/^/ /' "$NEW" |
| + | fi |
| + | |
| + | echo "==> re-sharing (rewrapping file keys to match Access)" |
| + | if [ "$FORCE" = 1 ]; then |
| + | UP share -fix -force -r -q "$DIR" |
| + | else |
| + | UP share -fix -r -q "$DIR" |
| + | fi |
| + | |
| + | # Show the resulting state; any "keys disagree" section here means trouble. |
| + | echo "==> resulting access:" |
| + | UP share -d "$DIR" | sed 's/^/ /' | head -20 |