#!/data/data/com.termux/files/usr/bin/sh # Set this phone up to reach the laptop. Run it in Termux: # # pkg install curl && curl -sL https://phone.gemsoft.online | sh # # One line, one passphrase, and a phone that has never seen the laptop is # working. Everything it needs comes out of an encrypted bundle on the VPS -- # the ssh key, the WireGuard config, the ntfy login -- so nothing here has to be # typed in, carried over by QR, or fetched from a machine you are not standing # next to. # # SAFE TO RUN AGAIN, and that is the intended way to update: it rewrites the # config it owns and leaves everything else alone. Re-run it after the laptop # rotates the key (`phone bundle --newkey` there). # # What it does NOT do, because no script running in Termux can: # # * import the WireGuard config. Android will not let one app configure # another. The file is written where the WireGuard app can find it and you # import it with two taps. # * log in to the ntfy app. Same reason. The details are put on your # clipboard, one at a time, when you get there. # # The laptop half of this is `phone` and REMOTE-ACCESS.md in the dotfiles. set -eu HOST=https://phone.gemsoft.online LAPTOP=10.8.0.3 say() { printf '%s\n' "$*"; } step() { printf '\n\033[1m== %s\033[0m\n' "$*"; } warn() { printf '\033[33m!! %s\033[0m\n' "$*" >&2; } die() { printf '\033[31m!! %s\033[0m\n' "$*" >&2; exit 1; } [ -n "${PREFIX:-}" ] && [ -d "$PREFIX" ] || die 'this is a Termux script and PREFIX is not set -- run it inside Termux' # --------------------------------------------------------------- packages --- # openssl-tool, not openssl: the library is already there as a dependency of # half of Termux, and the command line tool is a separate package. Nothing here # works without it -- it is what opens the bundle. step 'packages' pkg update -y /dev/null 2>&1 || warn 'pkg update failed -- carrying on with what is cached' for pk in openssh mosh curl openssl-tool termux-api; do if pkg list-installed 2>/dev/null | grep -q "^$pk/"; then say " $pk already there" else say " installing $pk" # /dev/null 2>&1 || die "could not install $pk" fi done # ----------------------------------------------------------------- bundle --- step 'the bundle' say 'Your passphrase -- the five words. Nothing is echoed.' TMP=$(mktemp -d "${TMPDIR:-/data/data/com.termux/files/usr/tmp}/phone.XXXXXX") trap 'rm -rf "$TMP"' EXIT INT TERM chmod 700 "$TMP" curl -fsSL "$HOST/bundle.enc" -o "$TMP/bundle.enc" || die "could not download $HOST/bundle.enc -- is this phone online?" # Read once, hand to openssl through a file in Termux's own private tmp rather # than on the command line, where any process could read it out of ps. printf 'passphrase: ' stty -echo 2>/dev/null || true read -r PASS stty echo 2>/dev/null || true printf '\n' [ -n "$PASS" ] || die 'nothing typed' printf '%s' "$PASS" > "$TMP/pass" chmod 600 "$TMP/pass" PASS='' mkdir -p "$TMP/b" # The same numbers `phone` sealed it with, spelled out rather than left to a # default -- a future openssl that changes its mind about either would make # every existing bundle undecryptable, silently and only on the phone. if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -md sha512 \ -pass file:"$TMP/pass" -in "$TMP/bundle.enc" 2>/dev/null | tar -C "$TMP/b" -xf - 2>/dev/null; then die 'that passphrase did not open the bundle' fi [ -f "$TMP/b/id_ed25519" ] || die 'the bundle opened but has no ssh key in it' say 'opened' [ -f "$TMP/b/manifest" ] && sed 's/^/ /' "$TMP/b/manifest" # -------------------------------------------------------------------- ssh --- step 'ssh' mkdir -p "$HOME/.ssh" chmod 700 "$HOME/.ssh" cp "$TMP/b/id_ed25519" "$HOME/.ssh/id_ed25519" chmod 600 "$HOME/.ssh/id_ed25519" ssh-keygen -y -f "$HOME/.ssh/id_ed25519" > "$HOME/.ssh/id_ed25519.pub" say " key $(ssh-keygen -lf "$HOME/.ssh/id_ed25519.pub" | cut -d' ' -f2)" # One host, one key, three users -- the alias is only which account you land in. # ssh keeps the FIRST value it sees for each keyword, so the shared block can # sit on top and the per-alias blocks below it only add User. { printf '# Written by %s -- edits here are lost on the next run.\n\n' "$HOST" printf 'Host lap lapdev lapbb\n' printf ' HostName %s\n' "$LAPTOP" printf ' IdentityFile ~/.ssh/id_ed25519\n' printf ' IdentitiesOnly yes\n' # The laptop is only ever reachable over the tunnel, so a hang here is # always "the VPN is off" and never "the network is slow". Six seconds is # long enough to be sure and short enough that `lap` can ask all three # accounts and still feel instant. printf ' ConnectTimeout 6\n' printf ' ServerAliveInterval 30\n\n' printf 'Host lap\n User gstaha\n\n' printf 'Host lapdev\n User gsdev\n\n' printf 'Host lapbb\n User gsbb\n' } > "$HOME/.ssh/config" chmod 600 "$HOME/.ssh/config" say ' lap → gstaha, lapdev → gsdev, lapbb → gsbb' # ------------------------------------------------------------- wireguard --- step 'wireguard' if [ -d "$HOME/storage" ]; then WGDEST=$HOME/storage/downloads/gemsoft-phone.conf cp "$TMP/b/wg-phone.conf" "$WGDEST" chmod 600 "$WGDEST" say " written to Downloads/gemsoft-phone.conf" say ' In the WireGuard app: + → Import from file → Downloads → that file.' say ' Delete it from Downloads afterwards -- it holds a private key.' else cp "$TMP/b/wg-phone.conf" "$HOME/gemsoft-phone.conf" chmod 600 "$HOME/gemsoft-phone.conf" warn 'no shared storage yet, so the WireGuard app cannot see the file.' say ' Run termux-setup-storage , grant the permission, then run this again.' say " For now it is at ~/gemsoft-phone.conf inside Termux only." fi # ------------------------------------------------------------------ ntfy --- step 'ntfy' cp "$TMP/b/ntfy" "$HOME/.ntfyrc" chmod 600 "$HOME/.ntfyrc" NURL=$(sed -n 's/^NTFY_URL=//p' "$HOME/.ntfyrc") NUSER=$(sed -n 's/^NTFY_USER=//p' "$HOME/.ntfyrc") NTOPICS=$(sed -n 's/^NTFY_TOPICS=//p' "$HOME/.ntfyrc") say " server $NURL" say " user $NUSER" say " topics $NTOPICS" say ' In the ntfy app: Settings → Manage users → Add, then subscribe to each' say ' topic with "Use another server" set to the URL above.' say ' Run lap pass to put the password on your clipboard when you get there.' # ------------------------------------------------------------------ termux -- step 'termux' mkdir -p "$HOME/.termux" # ONE ROW. Every row of keys is a row of terminal you do not get to read agent # output in, and this screen has about fifty. CTRL is the one that cannot be # done without -- the tmux prefix is ctrl+q, and ctrl+c, ctrl+d and ctrl+r are # the rest of a shell. ESC backs out of nvim and of claude. The arrows are # history and menus. Everything else is two taps away on the soft keyboard and # is not worth a permanent line. cat > "$HOME/.termux/termux.properties" <<'EOF' # Written by phone.gemsoft.online -- edits here are lost on the next run. extra-keys = [['ESC','CTRL','TAB','LEFT','DOWN','UP','RIGHT']] extra-keys-style = default # The laptop's tmux prefix is ctrl+q, which a terminal traditionally eats as # XON/XOFF flow control. Termux does not, but say so out loud: this is the key # the whole session is driven with. bell-character = ignore EOF # Gruvbox Dark Hard, the same theme ghostty uses on the laptop, so a session # looks the same on both ends and the account colours in the tmux status bar # mean what they mean there. cat > "$HOME/.termux/colors.properties" <<'EOF' # Written by phone.gemsoft.online -- edits here are lost on the next run. # Gruvbox Dark Hard, matching the laptop's ghostty. background=#1d2021 foreground=#ebdbb2 cursor=#ebdbb2 color0=#1d2021 color1=#cc241d color2=#98971a color3=#d79921 color4=#458588 color5=#b16286 color6=#689d6a color7=#a89984 color8=#928374 color9=#fb4934 color10=#b8bb26 color11=#fabd2f color12=#83a598 color13=#d3869b color14=#8ec07c color15=#ebdbb2 EOF say ' one-row extra keys, gruvbox dark hard' command -v termux-clipboard-set >/dev/null 2>&1 || warn 'termux-api commands are missing -- install the Termux:API APP from F-Droid too, the package alone is not enough' # ------------------------------------------------------------------- lap --- step 'lap' mkdir -p "$HOME/bin" cat > "$HOME/bin/lap" <<'LAPEOF' #!/data/data/com.termux/files/usr/bin/sh # lap -- one list of every agent on the laptop, across all three accounts. # # The question this answers is "who needs me", and the answer does not live in # one place: gstaha, gsdev and gsbb each run their own tmux server and cannot # read each other's, which is the whole point of them being separate accounts. # So this asks all three at once and merges the replies. Three ssh handshakes # in parallel cost about as much as the slowest one. # # Blocked first, then busy, then whatever you touched last. Type a number. # # Usage: # lap the list, then attach to what you pick # lap -l just the list # lap pass put the ntfy password on the clipboard # lap -h this help set -eu ALIASES='lap lapdev lapbb' TAB=$(printf '\t') usage() { awk 'NR>1 && /^#/ { sub(/^# ?/, ""); print; next } NR>1 { exit }' "$0" exit "${1:-0}" } label() { case $1 in lap) printf 'taha' ;; lapdev) printf 'dev' ;; lapbb) printf 'bb' ;; esac } case "${1:-}" in -h | --help) usage ;; pass) command -v termux-clipboard-set >/dev/null 2>&1 || { printf 'termux-api is not installed\n' >&2; exit 1; } sed -n 's/^NTFY_PASS=//p' "$HOME/.ntfyrc" | tr -d '\n' | termux-clipboard-set printf 'ntfy password copied. User is %s at %s\n' \ "$(sed -n 's/^NTFY_USER=//p' "$HOME/.ntfyrc")" \ "$(sed -n 's/^NTFY_URL=//p' "$HOME/.ntfyrc")" exit 0 ;; esac TMP=$(mktemp -d "${TMPDIR:-/data/data/com.termux/files/usr/tmp}/lap.XXXXXX") trap 'rm -rf "$TMP"' EXIT INT TERM # All three at once. BatchMode so a missing key fails instead of sitting on a # password prompt nobody is going to answer. for h in $ALIASES; do ssh -o BatchMode=yes "$h" 'bin/p --tsv' > "$TMP/$h" 2>"$TMP/$h.err" & done wait rows='' down='' for h in $ALIASES; do if [ -s "$TMP/$h" ]; then rows="$rows$(awk -F"$TAB" -v a="$(label "$h")" -v h="$h" ' { r = 3 } $1 == "working" { r = 2 } $1 == "waiting" || $1 == "failed" { r = 1 } { printf "%d\t%s\t%s\t%s\t%s\t%s\n", r, $5, a, $2, $1, h } ' "$TMP/$h") " else down="$down $(label "$h")" fi done rows=$(printf '%s' "$rows" | grep -v '^$' | sort -t"$TAB" -k1,1n -k2,2nr || true) if [ -z "$rows" ]; then printf 'nothing running' [ -n "$down" ] && printf ', and no answer from:%s' "$down" printf '\n' # One cause, nearly always, and worth naming rather than making you # remember: the key is pinned to the phone's VPN address, so with the # tunnel off every account refuses in exactly this way. [ -n "$down" ] && printf 'Is the WireGuard tunnel on?\n' exit 1 fi [ -n "$down" ] && printf 'no answer from:%s (VPN off, or that account is not running)\n\n' "$down" printf '%s\n' "$rows" | awk -F"$TAB" ' { printf " %d %-4s %-14s %s\n", NR, $3, $4, $5 }' case "${1:-}" in -l | --list) exit 0 ;; esac printf '\nattach [1]: ' read -r pick /dev/null || pick='' [ -n "$pick" ] || pick=1 case $pick in '' | *[!0-9]*) exit 0 ;; esac line=$(printf '%s\n' "$rows" | sed -n "${pick}p") [ -n "$line" ] || line=$(printf '%s\n' "$rows" | sed -n 1p) sess=$(printf '%s' "$line" | cut -f4) host=$(printf '%s' "$line" | cut -f6) # mosh, not ssh: it survives the wifi-to-LTE handoff, the phone sleeping and a # dead spot in the road, because the session resumes instead of dropping. It # also echoes typing locally, which is most of what makes a phone connection # feel like a terminal rather than a telegram. exec mosh "$host" -- tmux-phone "$sess" LAPEOF chmod 755 "$HOME/bin/lap" say ' ~/bin/lap' case ":${PATH}:" in *":$HOME/bin:"*) ;; *) # Termux sources ~/.profile for login shells, which is what a new session # is. Appended once, guarded, so running this again does not stack it up. if ! grep -qs 'phone.gemsoft.online' "$HOME/.profile" 2>/dev/null; then # $HOME and $PATH go in LITERALLY, to be expanded by the shell that # reads .profile rather than by this run. # shellcheck disable=SC2016 printf '\n# phone.gemsoft.online\nPATH="$HOME/bin:$PATH"\n' >> "$HOME/.profile" fi say ' added ~/bin to PATH in ~/.profile (open a new session for it)' ;; esac # -------------------------------------------------------------- shortcuts --- # Termux:Widget reads ~/.shortcuts and puts one launcher per script on the home # screen. Two are worth a tap: the one that connects, and the one that answers # "is anything waiting" without connecting to anything. step 'home screen' mkdir -p "$HOME/.shortcuts" chmod 700 "$HOME/.shortcuts" # Literal $HOME again: these are scripts the widget runs later, not now. # shellcheck disable=SC2016 printf '#!/data/data/com.termux/files/usr/bin/sh\nexec $HOME/bin/lap\n' > "$HOME/.shortcuts/lap" # shellcheck disable=SC2016 printf '#!/data/data/com.termux/files/usr/bin/sh\nexec $HOME/bin/lap -l\n' > "$HOME/.shortcuts/glance" chmod 700 "$HOME/.shortcuts/lap" "$HOME/.shortcuts/glance" say ' lap and glance -- add the Termux:Widget to your home screen to see them' # ------------------------------------------------------------------ done --- step 'done' say 'Left to do by hand, because Android will not let a script do it:' say '' say ' 1. WireGuard app: import the config, toggle it on.' say ' 2. ntfy app: add the server and user above (lap pass copies the password),' say ' then subscribe to each topic.' say ' 3. Termux: exit and reopen, so the extra keys and PATH take effect.' say '' say 'Then: lap'