#!/bin/sh # Hirdir installer for Linux servers: # curl -fsSL https://hirdir.com/install | sh # Settings (environment variables): # HIRDIR_DIR install folder (default ~/hirdir) # HIRDIR_PORT port on the server (default 3417) # HIRDIR_DOMAIN your own domain for HTTPS (default on a public IP: # .sslip.io; "none" turns HTTPS off) # HIRDIR_LOGIN passkey or password (default: passkey with HTTPS, # password without, e.g. on a home network) # HIRDIR_IMAGE container image (default ghcr.io/admonish/hirdir:latest) # HIRDIR_SKIP_PULL set to 1 to use a locally built image without pulling # HIRDIR_YES set to 1 to answer yes to every question set -eu PRODUCT_NAME="Hirdir" PORT="${HIRDIR_PORT:-3417}" DOMAIN_SETTING="${HIRDIR_DOMAIN:-}" LOGIN_SETTING="${HIRDIR_LOGIN:-}" IMAGE="${HIRDIR_IMAGE:-ghcr.io/admonish/hirdir:latest}" SKIP_PULL="${HIRDIR_SKIP_PULL:-}" ASSUME_YES="${HIRDIR_YES:-}" WILDCARD_DNS_SUFFIX="sslip.io" say() { printf '%s\n' "$*"; } fail() { printf 'Error: %s\n' "$*" >&2 exit 1 } ask_yes() { [ -n "$ASSUME_YES" ] && return 0 [ -r /dev/tty ] || return 1 printf '%s [Y/n] ' "$1" >/dev/tty read -r answer /dev/null 2>&1; then sudo "$@" else fail "this step needs root or sudo: $*" fi } is_private_ipv4() { case "$1" in 10.* | 127.* | 192.168.* | 169.254.*) return 0 ;; 172.1[6-9].* | 172.2[0-9].* | 172.3[01].*) return 0 ;; 100.6[4-9].* | 100.[7-9][0-9].* | 100.1[01][0-9].* | 100.12[0-7].*) return 0 ;; *) return 1 ;; esac } [ "$(uname -s)" = "Linux" ] || fail "this installer is for Linux servers. On Mac or Windows, install Docker Desktop and follow the README." command -v curl >/dev/null 2>&1 || fail "curl is required." # --- Docker ------------------------------------------------------------------ if ! command -v docker >/dev/null 2>&1; then say "Docker is not installed." ask_yes "Install Docker now, using Docker's official script (get.docker.com)?" || fail "Docker is required. Install it, then run this installer again." curl -fsSL https://get.docker.com | as_root sh fi if docker info >/dev/null 2>&1; then DOCKER="docker" elif as_root docker info >/dev/null 2>&1; then DOCKER="as_root docker" else fail "Docker is installed but not running. Start it (for example: sudo systemctl start docker) and try again." fi $DOCKER compose version >/dev/null 2>&1 || fail "Docker Compose v2 is missing. Install the docker-compose-plugin package." # --- Addresses, HTTPS and login ---------------------------------------------- primary_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i = 1; i < NF; i++) if ($i == "src") print $(i + 1)}' | head -n 1) case "$DOMAIN_SETTING" in none) DOMAIN="" ;; "") if [ -n "$primary_ip" ] && ! is_private_ipv4 "$primary_ip"; then DOMAIN="$(printf '%s' "$primary_ip" | tr '.' '-').$WILDCARD_DNS_SUFFIX" else DOMAIN="" fi ;; *) DOMAIN="$DOMAIN_SETTING" ;; esac mkdir -p "${HIRDIR_DIR:-$HOME/hirdir}" INSTALL_DIR=$(CDPATH='' cd -- "${HIRDIR_DIR:-$HOME/hirdir}" && pwd) # With HTTPS in front, Hirdir's own port only listens on the server itself, # so nothing travels unencrypted over the internet. if [ -n "$DOMAIN" ]; then BIND_ADDRESS="127.0.0.1" COMPOSE_FILES="$INSTALL_DIR/compose.yml:$INSTALL_DIR/compose.https.yml" else BIND_ADDRESS="0.0.0.0" COMPOSE_FILES="$INSTALL_DIR/compose.yml" fi # Passkeys need HTTPS (or localhost); without HTTPS, use a password. case "$LOGIN_SETTING" in passkey | password) LOGIN_METHOD="$LOGIN_SETTING" ;; "") if [ -n "$DOMAIN" ]; then LOGIN_METHOD="passkey"; else LOGIN_METHOD="password"; fi ;; *) fail "HIRDIR_LOGIN must be passkey or password." ;; esac # --- Files ------------------------------------------------------------------- umask 077 cat >"$INSTALL_DIR/compose.yml" <<'EOF' services: hirdir: image: ${HIRDIR_IMAGE} container_name: hirdir restart: unless-stopped ports: - "${HIRDIR_BIND}:${HIRDIR_PORT}:3417" environment: SETUP_CODE: ${SETUP_CODE:-} LOGIN_METHOD: ${LOGIN_METHOD:-passkey} volumes: - home:/home/hirdir volumes: home: EOF cat >"$INSTALL_DIR/compose.https.yml" <<'EOF' services: caddy: image: caddy:2 container_name: hirdir-caddy restart: unless-stopped command: caddy reverse-proxy --from ${HIRDIR_DOMAIN} --to hirdir:3417 ports: - "80:80" - "443:443" volumes: - caddy-data:/data - caddy-config:/config depends_on: - hirdir volumes: caddy-data: caddy-config: EOF { printf 'COMPOSE_FILE=%s\n' "$COMPOSE_FILES" printf 'HIRDIR_IMAGE=%s\n' "$IMAGE" printf 'HIRDIR_PORT=%s\n' "$PORT" printf 'HIRDIR_BIND=%s\n' "$BIND_ADDRESS" printf 'HIRDIR_DOMAIN=%s\n' "$DOMAIN" printf 'LOGIN_METHOD=%s\n' "$LOGIN_METHOD" printf 'SETUP_CODE=\n' } >"$INSTALL_DIR/.env" # The management command: start, update and reset-owner. cat >"$INSTALL_DIR/hirdir" <<'HIRDIR_COMMAND_EOF' #!/bin/sh # Manage this Hirdir installation (written by the installer): # ~/hirdir/hirdir start start, and print a setup link if unclaimed # ~/hirdir/hirdir update download the latest version and restart # ~/hirdir/hirdir reset-owner remove all accounts and print a new setup link # ~/hirdir/hirdir reset-owner passkey|password # same, and switch the login type set -eu PRODUCT_NAME="Hirdir" INSTALL_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) SERVICE="hirdir" CODE_ALPHABET="ABCDEFGHJKMNPQRSTUVWXYZ23456789" HEALTH_ATTEMPTS=60 HTTPS_ATTEMPTS=45 POLL_INTERVAL_SECONDS=2 ACCOUNT_FILES="users.json credentials.json password-users.json auth-secret" say() { printf '%s\n' "$*"; } fail() { printf 'Error: %s\n' "$*" >&2 exit 1 } as_root() { if [ "$(id -u)" -eq 0 ]; then "$@" elif command -v sudo >/dev/null 2>&1; then sudo "$@" else fail "this step needs root or sudo: $*" fi } [ -f "$INSTALL_DIR/.env" ] || fail "no installation found in $INSTALL_DIR (missing .env)." set -a . "$INSTALL_DIR/.env" set +a if docker info >/dev/null 2>&1; then DOCKER="docker" elif as_root docker info >/dev/null 2>&1; then DOCKER="as_root docker" else fail "Docker isn't running. Start it (for example: sudo systemctl start docker) and try again." fi compose() { $DOCKER compose --project-directory "$INSTALL_DIR" "$@"; } set_setup_code() { sed -i "s/^SETUP_CODE=.*/SETUP_CODE=$1/" "$INSTALL_DIR/.env" SETUP_CODE="$1" } new_setup_code() { raw_code=$(LC_ALL=C tr -dc "$CODE_ALPHABET" /dev/null 2>&1 } wait_until_healthy() { attempt=0 until curl -fsS "http://127.0.0.1:$HIRDIR_PORT/api/health" >/dev/null 2>&1; do attempt=$((attempt + 1)) if [ "$attempt" -ge "$HEALTH_ATTEMPTS" ]; then say "$PRODUCT_NAME did not start. Last log lines:" compose logs --tail 40 "$SERVICE" || true fail "startup failed." fi sleep "$POLL_INTERVAL_SECONDS" done } https_is_ready() { attempt=0 until curl -fsS "https://$HIRDIR_DOMAIN/api/health" >/dev/null 2>&1; do attempt=$((attempt + 1)) [ "$attempt" -ge "$HTTPS_ATTEMPTS" ] && return 1 sleep "$POLL_INTERVAL_SECONDS" done return 0 } ensure_qrencode() { command -v qrencode >/dev/null 2>&1 && return 0 command -v apt-get >/dev/null 2>&1 || return 0 if [ "$(id -u)" -eq 0 ] || sudo -n true >/dev/null 2>&1; then as_root apt-get install -y -qq qrencode >/dev/null 2>&1 || true fi } run_start() { if is_claimed; then claimed=1 set_setup_code "" else claimed="" set_setup_code "$(new_setup_code)" fi say "Starting $PRODUCT_NAME..." compose up -d --quiet-pull --remove-orphans wait_until_healthy if [ -n "$HIRDIR_DOMAIN" ]; then say "Checking the HTTPS certificate for $HIRDIR_DOMAIN..." if ! https_is_ready; then say "" say "$PRODUCT_NAME is running, but the HTTPS certificate for $HIRDIR_DOMAIN isn't ready." say "Usually ports 80 and 443 aren't open to the internet (check your provider's" say "firewall too). It can also be Let's Encrypt's limit of 5 certificates a week" say "for the same address, if this server was set up from scratch many times." say "Details: docker logs hirdir-caddy" say "When fixed, run: $INSTALL_DIR/hirdir start" exit 1 fi base_url="https://$HIRDIR_DOMAIN" else host_address=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i = 1; i < NF; i++) if ($i == "src") print $(i + 1)}' | head -n 1) base_url="http://${host_address:-localhost}:$HIRDIR_PORT" fi say "" if [ -n "$claimed" ]; then say "$PRODUCT_NAME is running and already set up." say "Open $base_url and sign in." return 0 fi link="$base_url/#$SETUP_CODE" ensure_qrencode say "$PRODUCT_NAME is running." say "" say "Finish setup in your browser:" say " $link" if command -v qrencode >/dev/null 2>&1; then qrencode -t ANSIUTF8 "$link" fi say "" say "Or open $base_url and enter the setup code: $SETUP_CODE" say "" if [ "$LOGIN_METHOD" = "password" ]; then say "You'll choose a username and password. Passkeys need HTTPS, which this" say "server doesn't have, so it uses a password instead." else say "You'll create a passkey (fingerprint, face or PIN) for your account." fi say "" say "Lost your login later? Run: $INSTALL_DIR/hirdir reset-owner" } run_update() { say "Downloading the latest $PRODUCT_NAME..." compose pull --quiet run_start } run_reset_owner() { new_login="${1:-}" case "$new_login" in "" | password) ;; passkey) [ -n "$HIRDIR_DOMAIN" ] || fail "passkeys need HTTPS. Run the installer again with HIRDIR_DOMAIN= first (or on a server with a public IP)." ;; *) fail "the login type must be passkey or password." ;; esac say "This removes every account and signs out every browser. Your projects," say "files and settings stay. Afterwards you get a new setup link to create" say "your account again." [ -n "$new_login" ] && say "The login type changes to: $new_login." if [ -z "${HIRDIR_YES:-}" ]; then [ -r /dev/tty ] || fail "run this in a terminal, or set HIRDIR_YES=1." printf 'Type "reset" to continue: ' >/dev/tty read -r answer /dev/null 2>&1 || true compose run --rm --no-deps --entrypoint sh "$SERVICE" -c \ "cd ~/.localterm 2>/dev/null && rm -f $ACCOUNT_FILES || true" if [ -n "$new_login" ]; then # Dropping the login config makes the container write a fresh one for # LOGIN_METHOD on its next start. compose run --rm --no-deps --entrypoint node "$SERVICE" -e ' const fs = require("node:fs"); const file = require("node:os").homedir() + "/.localterm/config.json"; try { const config = JSON.parse(fs.readFileSync(file, "utf8")); delete config.identity; fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); } catch {} ' sed -i "s/^LOGIN_METHOD=.*/LOGIN_METHOD=$new_login/" "$INSTALL_DIR/.env" LOGIN_METHOD="$new_login" fi say "Accounts removed." run_start } case "${1:-}" in start) run_start ;; update) run_update ;; reset-owner) run_reset_owner "${2:-}" ;; *) say "Usage: $INSTALL_DIR/hirdir " exit 2 ;; esac HIRDIR_COMMAND_EOF chmod 700 "$INSTALL_DIR/hirdir" # --- Image ------------------------------------------------------------------- compose() { $DOCKER compose --project-directory "$INSTALL_DIR" "$@"; } if [ -n "$SKIP_PULL" ] && $DOCKER image inspect "$IMAGE" >/dev/null 2>&1; then say "Using the local image $IMAGE." if [ -n "$DOMAIN" ]; then compose pull --quiet caddy; fi else say "Downloading $PRODUCT_NAME ($IMAGE)..." compose pull --quiet fi exec sh "$INSTALL_DIR/hirdir" start