NewOpenAgents/install-service.sh
cclohmar 8cef2acfa5 feat(service): --cors support + warn when username != opencode
- --cors ORIGIN (repeatable) and an interactive CORS prompt; adds --cors to ExecStart
- warn when a password is set with a username other than 'opencode' (the web UI always authenticates as opencode)
- README: --cors + the 'don't stack two basic-auth layers' note
2026-10-13 08:12:11 +00:00

378 lines
14 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#
# OpenAgents Control — Architect Fork · web UI service installer
# ============================================================================
# Creates (or removes) a systemd service that runs the OpenCode web UI.
#
# opencode serve --hostname <host> --port <port>
#
# Optionally protects it with OpenCode's own HTTP basic auth: the password is
# written to a root-readable env file (mode 600) referenced by the unit via
# EnvironmentFile= — the password never appears in the unit itself.
#
# Usage:
# ./install-service.sh # interactive (asks for a password)
# ./install-service.sh --port 4096
# ./install-service.sh --host 0.0.0.0 --port 8080
# ./install-service.sh --yes # non-interactive defaults
# ./install-service.sh --password secret # enable basic auth
# ./install-service.sh --username admin --password secret
# ./install-service.sh --no-password # ensure the server is unsecured
# ./install-service.sh --uninstall # stop and remove the service
# ./install-service.sh --dry-run # show the unit, change nothing
#
# Environment:
# OPENCODE_SERVICE_NAME unit name (default: opencode.service)
# OPENCODE_SERVER_PASSWORD basic-auth password (same as --password)
# OPENCODE_SERVER_USERNAME basic-auth username (default: opencode)
# OPENCODE_ENV_FILE env file path (default: /etc/opencode/opencode.env)
# OAC_FORCE_SYSTEMD 1/0 to force systemd detection (containers/CI)
# ============================================================================
set -euo pipefail
SERVICE_NAME="${OPENCODE_SERVICE_NAME:-opencode.service}"
DEFAULT_HOST="127.0.0.1"
DEFAULT_PORT="4096"
OPENCODE_BIN_DIR="${HOME}/.opencode/bin"
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
if [ -t 1 ]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; MAGENTA='\033[0;35m'
BOLD='\033[1m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; MAGENTA=''; BOLD=''; NC=''
fi
info() { printf "${BLUE}ℹ${NC} %s\n" "$1"; }
success() { printf "${GREEN}✓${NC} %s\n" "$1"; }
warn() { printf "${YELLOW}⚠${NC} %s\n" "$1"; }
error() { printf "${RED}✗${NC} %s\n" "$1" >&2; }
step() { printf "\n${MAGENTA}${BOLD}▶${NC} %s\n\n" "$1"; }
die() { error "$1"; exit 1; }
# Prompt on the controlling terminal — works even under `curl … | bash`.
have_tty() {
[ -t 1 ] || return 1
[ -t 0 ] && return 0
[ -r /dev/tty ]
}
tty_read() {
local __var="$1" __val=""
if [ -t 0 ] || [ ! -r /dev/tty ]; then read -r __val || true
else read -r __val </dev/tty || true; fi
printf -v "$__var" '%s' "$__val"
}
tty_read_secret() {
local __var="$1" __val=""
if [ -t 0 ] || [ ! -r /dev/tty ]; then read -rs __val || true
else read -rs __val </dev/tty || true; fi
printf '\n'
printf -v "$__var" '%s' "$__val"
}
have_opencode() {
command -v opencode >/dev/null 2>&1 && return 0
[ -x "${OPENCODE_BIN_DIR}/opencode" ] && return 0
return 1
}
opencode_path() {
if command -v opencode >/dev/null 2>&1; then command -v opencode
else printf '%s' "${OPENCODE_BIN_DIR}/opencode"; fi
}
is_systemd() {
if [ "${OAC_FORCE_SYSTEMD:-}" = "1" ]; then return 0; fi
if [ "${OAC_FORCE_SYSTEMD:-}" = "0" ]; then return 1; fi
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]
}
# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
HOST=""; PORT=""; WORKDIR=""; SVC_USER=""; SVC_GROUP=""
ENABLE="" # 1 | 0 | empty=ask
CORS="" # space-separated extra allowed origins
SVC_USERNAME="${OPENCODE_SERVER_USERNAME:-opencode}"
PASSWORD="${OPENCODE_SERVER_PASSWORD:-}"
PASSWORD_SET=0
[ -n "$PASSWORD" ] && PASSWORD_SET=1
ENV_FILE="${OPENCODE_ENV_FILE:-/etc/opencode/opencode.env}"
ASSUME_YES=0
DRY_RUN=0
UNINSTALL=0
usage() {
cat <<'EOF'
OpenAgents Control — Architect Fork · web UI service installer
Creates (or removes) a systemd service that runs the OpenCode web UI
(opencode serve). Optionally protects it with HTTP basic auth.
Usage: install-service.sh [OPTIONS]
Options:
--host HOST bind address (default: 127.0.0.1)
--port PORT port (default: 4096)
--dir PATH working directory (default: current dir)
--user USER run the service as USER (default: you)
--enable start and enable on boot
--no-enable install only; do not start
--cors ORIGIN allow this origin (repeatable); use if the web UI is
served from a different origin than the server
--password PW enable basic auth with this password
--username USER basic-auth username (default: opencode)
--no-password do not set a password (server unsecured)
--env-file PATH where to store credentials (default: /etc/opencode/opencode.env)
-y, --yes non-interactive (defaults; no prompts)
--uninstall stop, disable and remove the service
--dry-run show the unit and exit without changing anything
-h, --help show this help
Environment:
OPENCODE_SERVICE_NAME, OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME,
OPENCODE_ENV_FILE, OAC_FORCE_SYSTEMD
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--host) [ $# -ge 2 ] || die "--host needs a value"; HOST="$2"; shift ;;
--host=*) HOST="${1#*=}" ;;
--port) [ $# -ge 2 ] || die "--port needs a value"; PORT="$2"; shift ;;
--port=*) PORT="${1#*=}" ;;
--dir) [ $# -ge 2 ] || die "--dir needs a value"; WORKDIR="$2"; shift ;;
--dir=*) WORKDIR="${1#*=}" ;;
--user) [ $# -ge 2 ] || die "--user needs a value"; SVC_USER="$2"; shift ;;
--user=*) SVC_USER="${1#*=}" ;;
--username) [ $# -ge 2 ] || die "--username needs a value"; SVC_USERNAME="$2"; shift ;;
--username=*) SVC_USERNAME="${1#*=}" ;;
--password) [ $# -ge 2 ] || die "--password needs a value"; PASSWORD="$2"; PASSWORD_SET=1; shift ;;
--password=*) PASSWORD="${1#*=}"; PASSWORD_SET=1 ;;
--no-password) PASSWORD=""; PASSWORD_SET=0 ;;
--env-file) [ $# -ge 2 ] || die "--env-file needs a value"; ENV_FILE="$2"; shift ;;
--env-file=*) ENV_FILE="${1#*=}" ;;
--enable) ENABLE=1 ;;
--no-enable) ENABLE=0 ;;
--cors) [ $# -ge 2 ] || die "--cors needs an origin"; CORS="${CORS} $2"; shift ;;
--cors=*) CORS="${CORS} ${1#*=}" ;;
-y|--yes) ASSUME_YES=1 ;;
--uninstall) UNINSTALL=1 ;;
--dry-run) DRY_RUN=1 ;;
-h|--help) usage; exit 0 ;;
*) error "Unknown option: $1"; echo; usage; exit 1 ;;
esac
shift
done
# ---------------------------------------------------------------------------
# Privilege
# ---------------------------------------------------------------------------
SUDO=""
[ "$(id -u)" != "0" ] && SUDO="sudo"
UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}"
# ---------------------------------------------------------------------------
# Uninstall
# ---------------------------------------------------------------------------
if [ "$UNINSTALL" = 1 ]; then
if ! is_systemd; then
warn "systemd not detected — nothing to uninstall here."; exit 0
fi
if [ "$DRY_RUN" = 1 ]; then
info "[dry-run] would stop, disable and delete ${UNIT_PATH}"; exit 0
fi
$SUDO systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true
if [ -e "$UNIT_PATH" ]; then
$SUDO rm -f "$UNIT_PATH" && success "Removed ${UNIT_PATH}"
else
info "No unit at ${UNIT_PATH}"
fi
$SUDO systemctl daemon-reload 2>/dev/null || true
info "Credentials file left in place: ${ENV_FILE} (remove it manually if you like)."
exit 0
fi
# ---------------------------------------------------------------------------
# Locate OpenCode
# ---------------------------------------------------------------------------
have_opencode || die "OpenCode is not installed (looked on PATH and ${OPENCODE_BIN_DIR}). Install it first."
OC_BIN="$(opencode_path)"
# ---------------------------------------------------------------------------
# Prompt (interactive only)
# ---------------------------------------------------------------------------
INTERACTIVE=0
if [ "$ASSUME_YES" = 0 ] && have_tty; then INTERACTIVE=1; fi
if [ "$INTERACTIVE" = 1 ] && [ "$DRY_RUN" = 0 ]; then
printf "\n${BOLD}Set up the OpenCode web UI as a systemd service?${NC} [Y/n]: "
ans=""; tty_read ans
case "$ans" in
[Nn]*) info "Cancelled."; exit 0 ;;
esac
fi
[ -n "$HOST" ] || HOST="$DEFAULT_HOST"
[ -n "$PORT" ] || PORT="$DEFAULT_PORT"
[ -n "$WORKDIR" ] || WORKDIR="$(pwd)"
[ -n "$SVC_USER" ] || SVC_USER="$(id -un)"
[ -n "$SVC_GROUP" ] || SVC_GROUP="$(id -gn)"
if [ "$INTERACTIVE" = 1 ] && [ "$DRY_RUN" = 0 ]; then
v=""
printf " Bind address [%s]: " "$HOST"; tty_read v; [ -n "$v" ] && HOST="$v"
printf " Port [%s]: " "$PORT"; tty_read v; [ -n "$v" ] && PORT="$v"
printf " Working directory [%s]: " "$WORKDIR"; tty_read v; [ -n "$v" ] && WORKDIR="$v"
printf " Start now and enable on boot? [Y/n]: "; v=""; tty_read v
case "$v" in [Nn]*) ENABLE=0 ;; *) ENABLE=1 ;; esac
printf " Extra allowed origins (CORS), space-separated [none]: "; v=""; tty_read v
[ -n "$v" ] && CORS="${CORS} $v"
if [ "$PASSWORD_SET" = 0 ]; then
printf " Protect the server with a password? [y/N]: "; v=""; tty_read v
case "$v" in
[Yy]*)
pw1=""; printf " Password: "; tty_read_secret pw1
pw2=""; printf " Confirm password: "; tty_read_secret pw2
if [ -z "$pw1" ]; then
warn "Empty password — the server will be unsecured."
elif [ "$pw1" != "$pw2" ]; then
warn "Passwords do not match — the server will be unsecured."
else
PASSWORD="$pw1"; PASSWORD_SET=1
fi
;;
esac
fi
fi
[ -n "$ENABLE" ] || ENABLE=1
# ---------------------------------------------------------------------------
# Validate the password (env-file safety)
# ---------------------------------------------------------------------------
if [ "$PASSWORD_SET" = 1 ]; then
case "$PASSWORD" in
*$'\n'*) warn "Password contains a newline — disabling auth."; PASSWORD_SET=0 ;;
esac
case "$SVC_USERNAME" in
*$'\n'*) SVC_USERNAME="opencode" ;;
esac
if [ "$SVC_USERNAME" != "opencode" ]; then
warn "Username '${SVC_USERNAME}' set — the OpenCode web UI always authenticates as 'opencode', so the browser login will fail unless you keep username 'opencode'."
fi
fi
# ---------------------------------------------------------------------------
# Build the unit
# ---------------------------------------------------------------------------
ENV_LINE=""
[ "$PASSWORD_SET" = 1 ] && ENV_LINE="EnvironmentFile=-${ENV_FILE}"
# Build the optional --cors flags
CORS_ARGS=""
for o in $CORS; do CORS_ARGS="${CORS_ARGS} --cors ${o}"; done
UNIT="$(mktemp 2>/dev/null || mktemp -t oac-unit)"
cat > "$UNIT" <<EOF
[Unit]
Description=opencode server (web UI)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${SVC_USER}
Group=${SVC_GROUP}
${ENV_LINE}
WorkingDirectory=${WORKDIR}
ExecStart=${OC_BIN} serve --hostname ${HOST} --port ${PORT}${CORS_ARGS}
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
cleanup() { rm -f "$UNIT"; }
trap cleanup EXIT INT TERM
# ---------------------------------------------------------------------------
# Install
# ---------------------------------------------------------------------------
if ! is_systemd; then
warn "systemd not detected — cannot install a service here."
info "Start the web UI manually: ${OC_BIN} serve --hostname ${HOST} --port ${PORT}"
[ "$PASSWORD_SET" = 1 ] && info "with auth: OPENCODE_SERVER_PASSWORD=*** ${OC_BIN} serve ..."
exit 0
fi
echo
info "Unit to install:"
echo "------------------------------------------------------------"
cat "$UNIT"
echo "------------------------------------------------------------"
if [ "$DRY_RUN" = 1 ]; then
[ "$PASSWORD_SET" = 1 ] && info "[dry-run] would write credentials to ${ENV_FILE} (mode 600)"
warn "Dry run — nothing was written."
exit 0
fi
if [ "$INTERACTIVE" = 1 ]; then
printf "Install to %s? [y/N]: " "$UNIT_PATH"
ans=""; tty_read ans
case "$ans" in
[Yy]*) ;;
*) info "Cancelled."; exit 0 ;;
esac
fi
# Write the credentials file (root-readable) before pointing the unit at it.
if [ "$PASSWORD_SET" = 1 ]; then
esc="$(printf '%s' "$PASSWORD" | sed 's/\\/\\\\/g; s/"/\\"/g')"
if ! $SUDO mkdir -p "$(dirname "$ENV_FILE")"; then
die "Could not create $(dirname "$ENV_FILE")"
fi
if ! { printf 'OPENCODE_SERVER_PASSWORD="%s"\n' "$esc"
if [ "$SVC_USERNAME" != "opencode" ]; then
printf 'OPENCODE_SERVER_USERNAME="%s"\n' "$SVC_USERNAME"
fi
} | $SUDO tee "$ENV_FILE" >/dev/null; then
die "Could not write ${ENV_FILE}"
fi
$SUDO chmod 600 "$ENV_FILE"
success "Wrote credentials to ${ENV_FILE} (mode 600)"
fi
# Back up an existing unit before replacing it
if [ -e "$UNIT_PATH" ]; then
backup="${UNIT_PATH}.bak.$(date +%Y%m%d-%H%M%S)"
if $SUDO cp "$UNIT_PATH" "$backup" 2>/dev/null; then
info "Backed up the existing unit to ${backup}"
fi
fi
if ! $SUDO cp "$UNIT" "$UNIT_PATH"; then
die "Could not write ${UNIT_PATH} (needs root). Re-run with sudo."
fi
$SUDO systemctl daemon-reload 2>/dev/null || true
if [ "$ENABLE" = 1 ]; then
if $SUDO systemctl enable --now "$SERVICE_NAME" 2>/dev/null; then
success "Web UI service enabled — http://${HOST}:${PORT}"
else
warn "Unit installed; enable it later: sudo systemctl enable --now ${SERVICE_NAME}"
fi
else
success "Unit installed — start it with: sudo systemctl start ${SERVICE_NAME}"
fi
if [ "$PASSWORD_SET" = 1 ]; then
info "Auth: HTTP basic — username '${SVC_USERNAME}', password from ${ENV_FILE}"
else
warn "No password set — the server is unsecured. Put it behind your proxy's auth."
fi