#!/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' # --------------------------------------------------------------- one part --- # `sh setup --keys` writes the Termux config and nothing else: no packages, no # bundle, no passphrase. A keyboard row is not worth typing five words for, and # it is the part most likely to change on its own. # # IT EXISTS SO NOBODY PIPES SED INTO A CONFIG FILE. The alternative was a # hand-built one-liner that pulled this block out of the script with a sed # range and a redirect -- and a redirect truncates before the download it is # waiting on can fail. It failed, silently, and left a zero-byte properties # file that Termux reads as "no config at all" and answers with its defaults. # A blank config and a working one look identical on screen. Two evenings. ONLY_KEYS=no case ${1:-} in --keys) ONLY_KEYS=yes ;; '') ;; *) die "unknown option: $1 -- this takes --keys, or nothing at all" ;; esac # Unindented on purpose: a heredoc terminator has to sit at the start of its # line, and every one of these writes a file verbatim. termux_config() { mkdir -p "$HOME/.termux" # TWO ROWS, WHICH IS NOT WHAT THIS ARGUED A DAY AGO. The case for one was real # and it is still true: every row of keys is a row of terminal you do not get to # read agent output in, and this screen has about fifty. It lost to use. Termux's # own two-row default ran here by accident for an evening -- an empty properties # file falls back to it -- and the verdict was to keep it. Home, End, PgUp and # PgDn are the four keys a phone has no other way to send at all, and one tap # beats one long press for keys you reach for while reading. # # SPELLED OUT RATHER THAN LEFT TO FALL BACK TO, and that is the other half of # what the evening taught. An unset extra-keys and a broken one look identical # on screen, so a config that quietly stopped being applied is invisible -- # which is exactly how it went unnoticed. Written down, the file says which. # # TWO POPUPS SURVIVE. A popup costs no height at all, and these two have no home # in either row: # # CTRL the whole tmux prefix in one press, since every tmux key here starts # with it and holding one is cheaper than chording two on glass # ESC F5, the project list -- the spare way in, not the way in: that list # is on prefix + a precisely because this phone has no F-row # # The rest is Termux's layout untouched, including `/` and `-`, which are there # because moving them is a change nobody asked for. cat > "$HOME/.termux/termux.properties" <<'EOF' # Written by phone.gemsoft.online -- edits here are lost on the next run. # Termux's own two rows. Long-press ESC or CTRL for the key underneath it. extra-keys = [[ \ {key: ESC, popup: F5}, '/', '-', 'HOME', 'UP', 'END', 'PGUP' \ ], [ \ 'TAB', {key: CTRL, popup: {macro: "CTRL q", display: "C-q"}}, 'ALT', \ 'LEFT', 'DOWN', 'RIGHT', 'PGDN' \ ]] 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 ' two rows of 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' # So that a keyboard change is a keyboard change and not "exit and reopen # Termux". It is the only thing here that can take effect without a new # session, so it may as well. if command -v termux-reload-settings >/dev/null 2>&1; then termux-reload-settings || warn 'reload failed -- exit and reopen Termux' fi } if [ "$ONLY_KEYS" = yes ]; then step 'termux' termux_config say '' say 'The keyboard is written and reloaded. Nothing else was touched.' exit 0 fi # --------------------------------------------------------------- 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?" # FROM /dev/tty, NEVER FROM STDIN. `curl -sL ... | sh` hands this script to the # shell on stdin, so a plain `read` reads the SCRIPT: it swallowed its own next # line as the passphrase, the parser lost its place, and the heredoc further # down was executed as commands ("bell-character: not found"). /dev/tty is the # terminal itself and the pipe does not touch it. # # Same reason stty is pointed at it explicitly -- with stdin a pipe, `stty # -echo` fails, so the passphrase would have been echoed to the screen even if # the read had worked. { : < /dev/tty; } 2>/dev/null || die 'no terminal to type a passphrase on -- save this script to a file and run it' printf 'passphrase: ' > /dev/tty stty -echo < /dev/tty 2>/dev/null || true read -r PASS < /dev/tty stty echo < /dev/tty 2>/dev/null || true printf '\n' > /dev/tty [ -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' termux_config # ------------------------------------------------------------------- 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' # AND SAY SOMETHING IF THE ADD-ON IS NOT THERE. An empty widget picker is # almost always Termux:Widget simply not being installed -- and it is easy to # believe you installed it, because a wrong-source one FAILS to install with # nothing but "App not installed" to go on. # # That failure is by design and worth knowing: the add-ons declare # sharedUserId="com.termux", and Android refuses to give a package another # package's uid unless the two are signed by the same key. So they cannot be # mixed and matched -- but neither can a mismatched one install and then quietly # misbehave. If it is installed at all, it can read ~/.shortcuts. # # THIS OFTEN CANNOT ANSWER, and says so rather than going quiet. Since Android # 11, package visibility filtering hides the package list from ordinary apps, so # `pm list packages` run from Termux returns nothing at all -- on those phones # silence would read as "all fine". _pm=$(command -v pm 2>/dev/null) || _pm=/system/bin/pm _pkgs=$([ -x "$_pm" ] && "$_pm" list packages -U 2>/dev/null || true) if ! printf '%s\n' "$_pkgs" | grep -q '^package:com\.termux '; then say ' (cannot check the add-ons from here -- Android hides the package list' say ' from apps. If the widget picker has no Termux:Widget entry, it is not' say ' installed: get it from F-Droid, the same source as Termux itself.)' else for _app in widget api; do case $_app in widget) _name='Termux:Widget'; _why='the home-screen shortcuts above' ;; api) _name='Termux:API'; _why='lap pass, which copies the ntfy password' ;; esac printf '%s\n' "$_pkgs" | grep -q "^package:com\.termux\.$_app " || warn "$_name is not installed -- without it, $_why will not work" done fi # ------------------------------------------------------------------ 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 ~/bin is on PATH. The keyboard is' say ' already live -- this reloaded it.' say '' say 'Then: lap'