- --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
378 lines
14 KiB
Bash
Executable file
378 lines
14 KiB
Bash
Executable file
#!/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
|