arblant
firefox-vpn-client / core / scripts / install-systemd.sh
install-systemd.sh337 lines · 13.8 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337
#!/usr/bin/env bash
# Installs firefox-vpn-core as a systemd service on Linux, from the source
# tree or from a prebuilt binary. The release's install.sh (at the repository
# root) runs it with the downloaded binary and keeps it as the firefox-vpn
# command.
#
# The program and its data folder live together in INSTALL_DIR (portable
# layout): INSTALL_DIR/firefox-vpn-core and INSTALL_DIR/user-data. Settings and
# accounts are managed from the web dashboard (default 127.0.0.1:10820) or
# with the subcommands below.
set -euo pipefail

SERVICE_NAME="${SERVICE_NAME:-firefox-vpn}"
SERVICE_USER="${SERVICE_USER:-firefox-vpn}"
INSTALL_DIR="${INSTALL_DIR:-/opt/firefox-vpn}"
BINARY="${BINARY:-}"
PURGE="${PURGE:-0}"
# How the help and the messages name this script (install.sh: firefox-vpn).
SELF="${SELF:-$0}"
UNIT_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
BIN_PATH="$INSTALL_DIR/firefox-vpn-core"
DATA_DIR="$INSTALL_DIR/user-data"
# update runs the release's installer, which downloads the newest core,
# keeps the data folder (accounts, settings, logins), and replaces this
# command too; RELEASES_URL/latest leads to the newest release's tag.
INSTALLER_URL="${INSTALLER_URL:-https://raw.githubusercontent.com/armanlife/firefox-vpn-client/main/install.sh}"
RELEASES_URL="${RELEASES_URL:-https://github.com/armanlife/firefox-vpn-client/releases}"

# The command being run, for the messages.
COMMAND_NAME="${1:-}"
# The name people type (firefox-vpn), not the path.
NAME="$(basename "$SELF")"
# Colors on a terminal (unless NO_COLOR is set): errors in red, the menu.
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != dumb ]; then
  RED=$'\e[1;31m' OFF=$'\e[0m' BOLD=$'\e[1m' DIM=$'\e[2m' CYAN=$'\e[36m' FOX=$'\e[38;5;208m' GREEN=$'\e[32m'
else
  RED="" OFF="" BOLD="" DIM="" CYAN="" FOX="" GREEN=""
fi

# done_ok, done_fail: what a command did, in green or red.
done_ok() { printf '  %s✓%s %s\n' "$GREEN" "$OFF" "$*"; }
done_fail() { printf '  %s✗ %s%s\n' "$RED" "$*" "$OFF" >&2; return 1; }

log() { printf '[%s] %s\n' "$SERVICE_NAME" "$*"; }
die() { printf '[%s] error: %s\n' "$SERVICE_NAME" "$*" >&2; exit 1; }

usage() {
  cat <<EOF
Usage: sudo $NAME [command]

Without a command, on a terminal, a menu offers them by number.

Commands:
  install              Install or update the binary and the service, then start it
  update               Update to the newest release, keeping the accounts and settings
  uninstall            Stop and remove the service (PURGE=1 also deletes $INSTALL_DIR)
  status               Show the service state and the runtime status file
  start                Start the service
  stop                 Stop the service (until start, or the next boot when enabled)
  restart              Restart the service
  enable               Start the service with the server
  disable              Do not start the service with the server
  logs                 Show the service log
  add-account          Sign in to a Firefox Account on this terminal
  list-accounts        List accounts, status, and quota
  set-dashboard-login  Set or remove the dashboard username and password
  setup-server [user]  Open the dashboard and the proxy to the network: the dashboard
                       login for user (a random one without it) and a random proxy
                       login, each with a random password; print the logins

Environment:
  BINARY=path          Install this prebuilt binary instead of building from the source tree
  INSTALL_DIR=dir      Program and data folder (default /opt/firefox-vpn)
  SERVICE_NAME, SERVICE_USER

The dashboard is at http://<address>:10820/app/. It opens to other devices only
with a login (set-dashboard-login, or setup-server, which install.sh runs on a
new server); without one it answers this machine only, and an SSH tunnel
(ssh -L 10820:127.0.0.1:10820 host) reaches it.
EOF
}

# need_root: stops, in red, when the command needs root and was run
# without it.
need_root() {
  [ "$(id -u)" -ne 0 ] || return 0
  printf '%sPermission denied: this needs root. Run it with sudo:%s\n  sudo %s %s\n' \
    "$RED" "$OFF" "$(basename "$SELF")" "$COMMAND_NAME" >&2
  exit 1
}

repo_root() { cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd; }

build_binary() {
  local out="$1"
  if [ -n "$BINARY" ]; then
    [ -f "$BINARY" ] || die "BINARY=$BINARY does not exist"
    cp "$BINARY" "$out"
    return
  fi
  # Installed as the firefox-vpn command (install.sh), with no source tree.
  [ -d "$(repo_root)/cmd/firefox-vpn-core" ] ||
    die "no source tree to build from; to update, run: sudo $NAME update"
  command -v go >/dev/null 2>&1 || die "Go is not installed; install Go or pass BINARY=path/to/firefox-vpn-core"
  log "building from $(repo_root)"
  (cd "$(repo_root)" && CGO_ENABLED=0 go build -trimpath -o "$out" ./cmd/firefox-vpn-core)
}

ensure_user() {
  if ! id "$SERVICE_USER" >/dev/null 2>&1; then
    log "creating system user $SERVICE_USER"
    useradd --system --home-dir "$DATA_DIR" --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
  fi
}


write_unit() {
  cat >"$UNIT_FILE" <<EOF
[Unit]
Description=Firefox VPN SOCKS5 proxy and dashboard
After=network-online.target
Wants=network-online.target

[Service]
User=$SERVICE_USER
Group=$SERVICE_USER
WorkingDirectory=$INSTALL_DIR
ExecStart=$BIN_PATH
Restart=on-failure
RestartSec=10
UMask=0077
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=$DATA_DIR

[Install]
WantedBy=multi-user.target
EOF
}

run_as_service_user() {
  [ -x "$BIN_PATH" ] || die "not installed; run: sudo $SELF install"
  runuser -u "$SERVICE_USER" -- "$BIN_PATH" "$@"
}

cmd_install() {
  need_root
  command -v systemctl >/dev/null 2>&1 || die "systemd is required"
  local tmp
  tmp="$(mktemp)"
  # The value now: the local variable is gone when the script exits.
  trap "rm -f -- '$tmp'" EXIT
  build_binary "$tmp"
  ensure_user
  install -d -m 0755 -o root -g root "$INSTALL_DIR"
  install -d -m 0700 -o "$SERVICE_USER" -g "$SERVICE_USER" "$DATA_DIR"
  install -m 0755 -o root -g root "$tmp" "$BIN_PATH"
  write_unit
  systemctl daemon-reload
  systemctl enable "${SERVICE_NAME}.service" >/dev/null
  systemctl restart "${SERVICE_NAME}.service"
  log "installed $BIN_PATH; data in $DATA_DIR"
  log "dashboard: http://127.0.0.1:10820/app/ (add accounts there, or: sudo $SELF add-account)"
}

# latest_version: the newest release's version (1.2.3), from where its
# latest link leads; empty when GitHub cannot be reached.
latest_version() {
  local url=""
  if command -v curl >/dev/null 2>&1; then
    url="$(curl -fsSI -o /dev/null -w '%{redirect_url}' --max-time 20 "$RELEASES_URL/latest" 2>/dev/null || true)"
  elif command -v wget >/dev/null 2>&1; then
    # wget names the address twice, the second time with " [following]".
    url="$(wget -S --spider --max-redirect=0 -T 20 "$RELEASES_URL/latest" 2>&1 |
      sed -n 's/^ *[Ll]ocation: *\([^ ]*\).*/\1/p' | tr -d '\r' | head -n 1 || true)"
  fi
  case "$url" in
    */tag/v*) printf '%s\n' "${url##*/tag/v}" ;;
  esac
}

# newer <a> <b>: whether version a (major.minor.patch) is newer than b.
newer() {
  [ "$1" != "$2" ] && [ "$(printf '%s\n' "$1" "$2" | sort -V | tail -n 1)" = "$1" ]
}

# cmd_update: the newest release, through its installer, unless the one
# installed is as new. Failures return: in the menu, errexit is off.
cmd_update() {
  need_root
  [ -x "$BIN_PATH" ] || die "not installed; install it with: curl -fsSL $INSTALLER_URL | sudo bash"
  local current latest script
  current="$("$BIN_PATH" -version 2>/dev/null || true)"
  latest="$(latest_version)"
  if [ -n "$latest" ] && ! newer "$latest" "$current"; then
    done_ok "Already the newest version ($current)."
    return 0
  fi
  if [ -n "$latest" ]; then
    printf '  Updating %s to %s\n' "${current:-the installed version}" "$latest"
  else
    printf '  GitHub did not say which version is the newest; updating anyway\n'
  fi
  if command -v curl >/dev/null 2>&1; then
    script="$(curl -fsSL --retry 3 "$INSTALLER_URL")" || script=""
  elif command -v wget >/dev/null 2>&1; then
    script="$(wget -qO- "$INSTALLER_URL")" || script=""
  fi
  [ -n "$script" ] || { done_fail "Could not download the installer: $INSTALLER_URL"; return 1; }
  local -a vars=(INSTALL_DIR="$INSTALL_DIR" SERVICE_NAME="$SERVICE_NAME" SERVICE_USER="$SERVICE_USER")
  [ -z "$latest" ] || vars+=(VERSION="v$latest")
  env "${vars[@]}" bash -c "$script" install.sh server
}

cmd_uninstall() {
  need_root
  systemctl disable --now "${SERVICE_NAME}.service" >/dev/null 2>&1 || true
  rm -f "$UNIT_FILE"
  systemctl daemon-reload
  if [ "$PURGE" = "1" ]; then
    log "deleting $INSTALL_DIR including accounts and settings"
    rm -rf "$INSTALL_DIR"
    userdel "$SERVICE_USER" >/dev/null 2>&1 || true
  else
    log "service removed; $INSTALL_DIR kept (PURGE=1 deletes it)"
  fi
}

# cmd_status: a few lines: the service and its autostart from systemd, then
# the core's own (proxy, account, data left). systemctl status
# firefox-vpn has every detail.
cmd_status() {
  local active since auto=off
  active="$(systemctl is-active "${SERVICE_NAME}.service" 2>/dev/null || true)"
  [ "$(systemctl is-enabled "${SERVICE_NAME}.service" 2>/dev/null || true)" != enabled ] || auto=on
  printf '\n  %s%sFirefox VPN%s\n' "$BOLD" "$FOX" "$OFF"
  printf '  %s──────────────────────────────────────────────────────%s\n' "$DIM" "$OFF"
  if [ "$active" = active ]; then
    since="$(systemctl show -p ActiveEnterTimestamp --value "${SERVICE_NAME}.service" 2>/dev/null || true)"
    since="$(date -d "$since" '+%b %-d %H:%M' 2>/dev/null || true)"
    printf '    %-11s %s●%s %srunning%s       %s\n' "Service" "$GREEN" "$OFF" "$BOLD" "$OFF" "${since:+since $since}"
  else
    printf '    %-11s %s●%s %sstopped%s       start it: %s start\n' "Service" "$RED" "$OFF" "$BOLD" "$OFF" "$NAME"
  fi
  printf '    %-11s %s\n' "Autostart" "$auto"
  if [ "$active" = active ]; then
    if [ "$(id -u)" -eq 0 ] && [ -x "$BIN_PATH" ]; then
      run_as_service_user -status 2>/dev/null || true
    else
      printf '    %s(the account and data left need root: sudo %s status)%s\n' "$DIM" "$NAME" "$OFF"
    fi
  fi
}

# menu: the everyday commands by number, for the command without
# arguments on a terminal; it runs the one picked.
menu() {
  local reply
  local -a cmds=(status start stop restart enable disable logs add-account list-accounts set-dashboard-login update)
  local -a labels=(
    "Status"
    "Start"
    "Stop"
    "Restart"
    "Start with the server"
    "Do not start with the server"
    "Show the log"
    "Add a Firefox Account"
    "List the accounts and their quota"
    "Set the panel's username and password"
    "Update to the newest version"
  )
  local i
  # Back to the menu after each command, until 0.
  while :; do
    printf '\n  %s%sFirefox VPN%s\n' "$BOLD" "$FOX" "$OFF"
    printf '  %s────────────────────────────────────────────────────────────────────────────%s\n' "$DIM" "$OFF"
    for i in "${!cmds[@]}"; do
      printf '  %s%2d)%s %-40s %s%s %s%s\n' "$CYAN" $((i + 1)) "$OFF" "${labels[$i]}" "$DIM" "$NAME" "${cmds[$i]}" "$OFF"
    done
    printf '  %s 0)%s Exit\n' "$CYAN" "$OFF"
    printf '  %s────────────────────────────────────────────────────────────────────────────%s\n' "$DIM" "$OFF"
    while :; do
      printf '  Enter a number [0-%d]: ' "${#cmds[@]}"
      read -r reply || exit 0
      case "$reply" in
        0 | "") exit 0 ;;
        *[!0-9]*) ;;
        *) if [ "$reply" -ge 1 ] && [ "$reply" -le "${#cmds[@]}" ]; then break; fi ;;
      esac
      printf '  %sNo such choice: %s%s\n' "$RED" "$reply" "$OFF"
    done
    COMMAND_NAME="${cmds[$((reply - 1))]}"
    printf '\n'
    # Ctrl+C ends the command (the log, for one), not the menu.
    trap ':' INT
    ( main "$COMMAND_NAME" ) || true
    trap - INT
    # A blank line below the prompt too, so it never sits on the window's
    # bottom edge: print it, then move the cursor back up onto the prompt.
    printf '\n\n\033[1A  %s↵  Press Enter to return to the menu%s ' "$CYAN" "$OFF"
    read -r reply || exit 0
  done
}

main() {
case "${1:-}" in
  install) cmd_install ;;
  update) cmd_update ;;
  uninstall) cmd_uninstall ;;
  status) cmd_status ;;
  logs) journalctl -u "${SERVICE_NAME}.service" -f ;;
  start)
    need_root
    if systemctl is-active --quiet "${SERVICE_NAME}.service"; then done_ok "Already running."
    else systemctl start "${SERVICE_NAME}.service" && done_ok "Started." || done_fail "Starting failed: $NAME logs"; fi
    ;;
  stop) need_root; systemctl stop "${SERVICE_NAME}.service" && done_ok "Stopped." || done_fail "Stopping failed" ;;
  restart) need_root; systemctl restart "${SERVICE_NAME}.service" && done_ok "Restarted." || done_fail "Restarting failed: $NAME logs" ;;
  enable) need_root; systemctl enable --quiet "${SERVICE_NAME}.service" && done_ok "Starts with the server." || done_fail "Failed" ;;
  disable) need_root; systemctl disable --quiet "${SERVICE_NAME}.service" && done_ok "Does not start with the server." || done_fail "Failed" ;;
  add-account) need_root; run_as_service_user -add-account && systemctl restart "${SERVICE_NAME}.service" ;;
  list-accounts) need_root; run_as_service_user -list-accounts ;;
  set-dashboard-login) need_root; run_as_service_user -set-dashboard-login && systemctl restart "${SERVICE_NAME}.service" ;;
  setup-server) need_root; run_as_service_user -setup-server "${2:--}" && systemctl restart "${SERVICE_NAME}.service" ;;
  "")
    # On a terminal, the menu; otherwise (scripts, pipes) the help.
    if [ -t 0 ] && [ -t 1 ]; then menu; else usage; fi
    ;;
  -h|--help|help) usage ;;
  *) usage; exit 2 ;;
esac
}

main "$@"