The server had no authentication: every /api route was open, CORS allowed any origin, and the identity written to the audit log came from the request body — the UI sent a hardcoded pf_user: 'admin', which any client could have set to anything it liked. Accounts live in pf.app_user with scrypt hashes from node's own crypto, so there is no native build step and the parameters travel with each hash. Sessions are express-session over connect-pg-simple in pf.session: a restart no longer signs everyone out, and a session can be revoked by deleting its row, which is how disable-user cuts off access immediately rather than at cookie expiry. Everything under /api except login/logout/me now requires a session, and the React app is mounted only once there is one — its load effects call the API on mount, so a logged-out mount would just fire a burst of 401s. A session that expires while the app is open lands back on the login screen: auth.jsx wraps fetch once rather than teaching every call site to check. Identity is now read from the session for pf_user, created_by and closed_by, and the body values are ignored. Hardened for an internet-facing deployment: trust proxy so req.ip and secure-cookie detection are right behind TLS termination, httpOnly + SameSite=Lax + Secure cookies, ten login failures per IP per fifteen minutes, one error message for unknown, wrong and disabled alike, and a fresh session id on success. CORS is off entirely unless CORS_ORIGIN names an origin — a wildcard alongside a session cookie would be CSRF by construction. The server refuses to boot without SESSION_SECRET rather than falling back to a guessable default. pf.sh grows add-user, passwd, list-users, disable-user and enable-user; passwords are read on stdin and hashed before they reach psql, so no plaintext in argv or shell history. install.sh generates the secret, applies 02_auth.sql, and creates the first account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
540 lines
17 KiB
Bash
Executable File
540 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pf.sh — Pivot Forecast management script
|
|
# Usage: ./pf.sh [deploy|start|stop|restart|status|logs|db-setup|config]
|
|
# ./pf.sh [add-user|passwd|list-users|disable-user|enable-user]
|
|
# ./pf.sh (interactive menu)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SERVICE_NAME="pf_app"
|
|
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
|
ENV_FILE="${APP_DIR}/.env"
|
|
MIN_NODE_MAJOR=20
|
|
|
|
# -- Colors ------------------------------------------------------------------
|
|
R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m'; B='\033[0;34m'; NC='\033[0m'
|
|
bold() { echo -e "\033[1m$*\033[0m"; }
|
|
info() { echo -e "${B}==>${NC} $*"; }
|
|
success() { echo -e "${G} ✓${NC} $*"; }
|
|
warn() { echo -e "${Y} !${NC} $*"; }
|
|
error() { echo -e "${R} ✗${NC} $*" >&2; }
|
|
die() { error "$*"; exit 1; }
|
|
|
|
# -- Helpers -----------------------------------------------------------------
|
|
|
|
require_systemd() {
|
|
systemctl --version &>/dev/null || die "systemd not found on this system."
|
|
}
|
|
|
|
node_binary() {
|
|
command -v node 2>/dev/null || true
|
|
}
|
|
|
|
check_node() {
|
|
local node
|
|
node=$(node_binary)
|
|
[[ -z "$node" ]] && die "node not found. Install Node.js >= ${MIN_NODE_MAJOR}."
|
|
local ver
|
|
ver=$(node --version | sed 's/v//')
|
|
local major="${ver%%.*}"
|
|
if (( major < MIN_NODE_MAJOR )); then
|
|
die "Node.js ${ver} found; requires >= ${MIN_NODE_MAJOR}. Please upgrade."
|
|
fi
|
|
success "Node.js ${ver}"
|
|
}
|
|
|
|
require_env() {
|
|
[[ -f "$ENV_FILE" ]] || die ".env not found. Run: ./pf.sh config"
|
|
}
|
|
|
|
load_env() {
|
|
require_env
|
|
set -a; source "$ENV_FILE"; set +a
|
|
}
|
|
|
|
sudo_if_needed() {
|
|
# Returns "sudo" if we're not root, empty string if we are
|
|
[[ "$EUID" -eq 0 ]] && echo "" || echo "sudo"
|
|
}
|
|
|
|
service_installed() {
|
|
[[ -f "$SERVICE_FILE" ]]
|
|
}
|
|
|
|
require_service() {
|
|
service_installed || die "systemd service not installed. Run: ./pf.sh install-service"
|
|
}
|
|
|
|
# The keys cmd_config manages; anything else in .env is left alone.
|
|
ENV_KEYS=(DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD PORT SESSION_SECRET COOKIE_SECURE)
|
|
|
|
env_get() {
|
|
[[ -f "$ENV_FILE" ]] || return 0
|
|
grep -E "^$1=" "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' || true
|
|
}
|
|
|
|
# psql against the DB_* connection in .env; extra args are passed through.
|
|
run_psql() {
|
|
PGPASSWORD="${DB_PASSWORD:-}" psql \
|
|
-h "${DB_HOST:-localhost}" \
|
|
-p "${DB_PORT:-5432}" \
|
|
-U "${DB_USER}" \
|
|
-d "${DB_NAME}" \
|
|
"$@"
|
|
}
|
|
|
|
db_ping() {
|
|
load_env
|
|
if [[ -z "${DB_NAME:-}" || -z "${DB_USER:-}" ]]; then
|
|
warn "DB_NAME / DB_USER not set in .env"
|
|
return 1
|
|
fi
|
|
# Use psql if available for a real connectivity check
|
|
if command -v psql &>/dev/null; then
|
|
run_psql -tAc "SELECT 1" &>/dev/null && return 0 || return 1
|
|
else
|
|
warn "psql not in PATH — skipping live DB check"
|
|
return 0
|
|
fi
|
|
}
|
|
|
|
# -- Commands ----------------------------------------------------------------
|
|
|
|
cmd_deploy() {
|
|
echo; bold "Deploying Pivot Forecast"
|
|
echo " App dir: $APP_DIR"
|
|
echo
|
|
|
|
check_node
|
|
require_env
|
|
|
|
info "Pulling latest from git…"
|
|
git -C "$APP_DIR" pull
|
|
|
|
info "Installing server dependencies…"
|
|
npm --prefix "$APP_DIR" install --omit=dev
|
|
|
|
info "Installing UI dependencies…"
|
|
npm --prefix "$APP_DIR/ui" install
|
|
|
|
info "Building UI…"
|
|
npm --prefix "$APP_DIR/ui" run build
|
|
|
|
if service_installed; then
|
|
info "Restarting service…"
|
|
cmd_restart
|
|
else
|
|
warn "Service not installed — server not started."
|
|
echo " Run: ./pf.sh install-service"
|
|
fi
|
|
|
|
success "Deploy complete."
|
|
}
|
|
|
|
cmd_start() {
|
|
require_systemd; require_service
|
|
info "Starting ${SERVICE_NAME}…"
|
|
$(sudo_if_needed) systemctl start "$SERVICE_NAME"
|
|
success "Started."
|
|
}
|
|
|
|
cmd_stop() {
|
|
require_systemd; require_service
|
|
info "Stopping ${SERVICE_NAME}…"
|
|
$(sudo_if_needed) systemctl stop "$SERVICE_NAME"
|
|
success "Stopped."
|
|
}
|
|
|
|
cmd_restart() {
|
|
require_systemd; require_service
|
|
info "Restarting ${SERVICE_NAME}…"
|
|
$(sudo_if_needed) systemctl restart "$SERVICE_NAME"
|
|
success "Restarted."
|
|
}
|
|
|
|
cmd_status() {
|
|
require_systemd
|
|
echo
|
|
bold "System service"
|
|
if service_installed; then
|
|
systemctl status "$SERVICE_NAME" --no-pager -l || true
|
|
else
|
|
warn "Service not installed — run: ./pf.sh install-service"
|
|
fi
|
|
|
|
echo
|
|
bold "Database"
|
|
if db_ping; then
|
|
success "DB reachable"
|
|
else
|
|
error "DB not reachable (check DATABASE_URL in .env)"
|
|
fi
|
|
|
|
echo
|
|
bold "Git"
|
|
git -C "$APP_DIR" log -1 --format=" Commit: %h %s (%ar)"
|
|
local branch
|
|
branch=$(git -C "$APP_DIR" rev-parse --abbrev-ref HEAD)
|
|
echo " Branch: $branch"
|
|
}
|
|
|
|
cmd_logs() {
|
|
require_systemd; require_service
|
|
info "Streaming logs (Ctrl-C to exit)…"
|
|
journalctl -u "$SERVICE_NAME" -f --no-pager
|
|
}
|
|
|
|
cmd_db_setup() {
|
|
require_env; load_env
|
|
[[ -n "${DB_NAME:-}" && -n "${DB_USER:-}" ]] || die "DB_NAME / DB_USER not set in .env — run: ./pf.sh config"
|
|
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
|
|
|
|
echo
|
|
bold "DB Setup — will run: setup_sql/01_schema.sql, setup_sql/02_auth.sql"
|
|
warn "This creates the pf schema, tables, and the account/session tables."
|
|
warn "Safe to re-run (CREATE IF NOT EXISTS)."
|
|
read -rp " Continue? [y/N] " confirm
|
|
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/01_schema.sql"
|
|
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/02_auth.sql"
|
|
success "Schema applied."
|
|
|
|
local n
|
|
n=$(run_psql -tAc "SELECT count(*) FROM pf.app_user WHERE is_active" 2>/dev/null || echo 0)
|
|
if [[ "${n:-0}" == "0" ]]; then
|
|
warn "No active accounts yet — create one with: ./pf.sh add-user"
|
|
fi
|
|
}
|
|
|
|
cmd_config() {
|
|
echo
|
|
bold "Configure .env"
|
|
echo " File: $ENV_FILE"
|
|
echo
|
|
|
|
local cur_host cur_port cur_name cur_user cur_pass cur_app_port
|
|
cur_host=$(env_get DB_HOST)
|
|
cur_port=$(env_get DB_PORT)
|
|
cur_name=$(env_get DB_NAME)
|
|
cur_user=$(env_get DB_USER)
|
|
cur_pass=$(env_get DB_PASSWORD)
|
|
cur_app_port=$(env_get PORT)
|
|
|
|
local input
|
|
read -rp " DB_HOST [${cur_host:-localhost}]: " input
|
|
local host="${input:-${cur_host:-localhost}}"
|
|
|
|
read -rp " DB_PORT [${cur_port:-5432}]: " input
|
|
local port="${input:-${cur_port:-5432}}"
|
|
|
|
read -rp " DB_NAME [${cur_name:-not set}]: " input
|
|
local name="${input:-$cur_name}"
|
|
[[ -z "$name" ]] && die "DB_NAME is required."
|
|
|
|
read -rp " DB_USER [${cur_user:-$USER}]: " input
|
|
local user="${input:-${cur_user:-$USER}}"
|
|
|
|
if [[ -n "$cur_pass" ]]; then
|
|
read -rsp " DB_PASSWORD [keep existing]: " input; echo
|
|
else
|
|
read -rsp " DB_PASSWORD: " input; echo
|
|
fi
|
|
local pass="${input:-$cur_pass}"
|
|
|
|
read -rp " PORT (app) [${cur_app_port:-3010}]: " input
|
|
local app_port="${input:-${cur_app_port:-3010}}"
|
|
|
|
# Session cookies are signed with this; regenerating it signs everyone out,
|
|
# so an existing secret is kept rather than re-rolled on every config run.
|
|
local secret
|
|
secret=$(env_get SESSION_SECRET)
|
|
if [[ -z "$secret" ]]; then
|
|
secret=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
|
|
success "SESSION_SECRET generated."
|
|
else
|
|
success "SESSION_SECRET kept (delete the line in .env to re-roll)."
|
|
fi
|
|
|
|
local cur_secure
|
|
cur_secure=$(env_get COOKIE_SECURE)
|
|
read -rp " COOKIE_SECURE — HTTPS-only cookie [${cur_secure:-true}]: " input
|
|
local cookie_secure="${input:-${cur_secure:-true}}"
|
|
|
|
# Rewrite the managed keys, carrying over any other lines already in .env.
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
cat > "$tmp" <<EOF
|
|
DB_HOST=${host}
|
|
DB_PORT=${port}
|
|
DB_NAME=${name}
|
|
DB_USER=${user}
|
|
DB_PASSWORD=${pass}
|
|
PORT=${app_port}
|
|
SESSION_SECRET=${secret}
|
|
COOKIE_SECURE=${cookie_secure}
|
|
EOF
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
local managed
|
|
managed=$(IFS='|'; echo "${ENV_KEYS[*]}")
|
|
grep -vE "^(${managed})=" "$ENV_FILE" | grep -vE '^[[:space:]]*$' >> "$tmp" || true
|
|
fi
|
|
mv "$tmp" "$ENV_FILE"
|
|
chmod 600 "$ENV_FILE"
|
|
success ".env written."
|
|
|
|
if db_ping; then
|
|
success "Database connection verified."
|
|
else
|
|
warn "Could not reach the database — double-check the DB_* settings."
|
|
fi
|
|
}
|
|
|
|
# -- Accounts ----------------------------------------------------------------
|
|
|
|
# Reads a password twice without echo and hashes it with lib/auth.js, so the
|
|
# plaintext never reaches argv, psql, or the shell history.
|
|
read_new_password() {
|
|
local p1 p2
|
|
# Prompts and their newlines go to stderr: stdout is the hash, and a stray
|
|
# newline there ends up prefixed to it by the caller's $( ).
|
|
read -rsp " Password: " p1; echo >&2
|
|
[[ -z "$p1" ]] && { error "Password cannot be empty."; return 1; }
|
|
read -rsp " Confirm : " p2; echo >&2
|
|
[[ "$p1" != "$p2" ]] && { error "Passwords do not match."; return 1; }
|
|
printf '%s' "$p1" | node "${APP_DIR}/lib/auth.js" hash
|
|
}
|
|
|
|
# psql single-quoted literal: double any embedded quote.
|
|
sql_lit() { printf "%s" "${1//\'/\'\'}"; }
|
|
|
|
cmd_add_user() {
|
|
require_env; load_env
|
|
check_node >/dev/null
|
|
|
|
echo; bold "Add account"
|
|
local username display hash
|
|
read -rp " Username: " username
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
read -rp " Display name [${username}]: " display
|
|
display="${display:-$username}"
|
|
|
|
hash=$(read_new_password) || return 1
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH ins AS (
|
|
INSERT INTO pf.app_user (username, pass_hash, display_name)
|
|
VALUES ('$(sql_lit "$username")', '$(sql_lit "$hash")', '$(sql_lit "$display")')
|
|
ON CONFLICT (username) DO NOTHING
|
|
RETURNING id
|
|
) SELECT id FROM ins" | grep -q . \
|
|
&& success "Account '${username}' created." \
|
|
|| die "Account '${username}' already exists — change its password with: ./pf.sh passwd"
|
|
}
|
|
|
|
cmd_passwd() {
|
|
require_env; load_env
|
|
check_node >/dev/null
|
|
|
|
echo; bold "Change password"
|
|
local username hash
|
|
read -rp " Username: " username
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
|
|
hash=$(read_new_password) || return 1
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH upd AS (
|
|
UPDATE pf.app_user SET pass_hash = '$(sql_lit "$hash")'
|
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
|
RETURNING id
|
|
) SELECT id FROM upd" | grep -q . \
|
|
&& success "Password updated for '${username}'." \
|
|
|| die "No such account: ${username}"
|
|
}
|
|
|
|
cmd_list_users() {
|
|
require_env; load_env
|
|
echo; bold "Accounts"
|
|
run_psql -c "
|
|
SELECT username, display_name, is_active,
|
|
to_char(last_login_at, 'YYYY-MM-DD HH24:MI') AS last_login
|
|
FROM pf.app_user ORDER BY username"
|
|
}
|
|
|
|
# Deactivating leaves the row (and its history) in place, and drops any live
|
|
# session so the account loses access immediately rather than at cookie expiry.
|
|
cmd_disable_user() {
|
|
require_env; load_env
|
|
local username="${1:-}"
|
|
[[ -z "$username" ]] && { read -rp " Username to disable: " username; }
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH upd AS (
|
|
UPDATE pf.app_user SET is_active = false
|
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
|
RETURNING id
|
|
) SELECT id FROM upd" | grep -q . \
|
|
|| die "No such account: ${username}"
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -c "
|
|
DELETE FROM pf.session
|
|
WHERE sess::jsonb -> 'user' ->> 'username' ILIKE '$(sql_lit "$username")'" >/dev/null
|
|
success "Account '${username}' disabled and signed out."
|
|
}
|
|
|
|
cmd_enable_user() {
|
|
require_env; load_env
|
|
local username="${1:-}"
|
|
[[ -z "$username" ]] && { read -rp " Username to enable: " username; }
|
|
[[ -z "$username" ]] && die "Username is required."
|
|
|
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
|
WITH upd AS (
|
|
UPDATE pf.app_user SET is_active = true
|
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
|
RETURNING id
|
|
) SELECT id FROM upd" | grep -q . \
|
|
&& success "Account '${username}' enabled." \
|
|
|| die "No such account: ${username}"
|
|
}
|
|
|
|
cmd_install_service() {
|
|
require_systemd
|
|
require_env
|
|
|
|
local node_path
|
|
node_path=$(node_binary)
|
|
[[ -z "$node_path" ]] && die "node not found — install Node.js first."
|
|
|
|
local run_user="$USER"
|
|
local s
|
|
s=$(sudo_if_needed)
|
|
|
|
echo
|
|
bold "Install systemd service"
|
|
echo " Service file : $SERVICE_FILE"
|
|
echo " Run as user : $run_user"
|
|
echo " App dir : $APP_DIR"
|
|
echo " Node binary : $node_path"
|
|
echo
|
|
read -rp " Continue? [y/N] " confirm
|
|
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
|
|
|
|
$s tee "$SERVICE_FILE" > /dev/null <<EOF
|
|
[Unit]
|
|
Description=Pivot Forecast App
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=${run_user}
|
|
WorkingDirectory=${APP_DIR}
|
|
EnvironmentFile=${ENV_FILE}
|
|
ExecStart=${node_path} ${APP_DIR}/server.js
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=${SERVICE_NAME}
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
$s systemctl daemon-reload
|
|
$s systemctl enable "$SERVICE_NAME"
|
|
success "Service installed and enabled."
|
|
echo " Start now with: ./pf.sh start"
|
|
}
|
|
|
|
cmd_uninstall_service() {
|
|
require_systemd
|
|
service_installed || { warn "Service not installed."; return; }
|
|
|
|
echo
|
|
warn "This will stop and remove the systemd service (does not touch app files)."
|
|
read -rp " Continue? [y/N] " confirm
|
|
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
|
|
|
|
local s
|
|
s=$(sudo_if_needed)
|
|
$s systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
|
$s systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
|
$s rm -f "$SERVICE_FILE"
|
|
$s systemctl daemon-reload
|
|
success "Service removed."
|
|
}
|
|
|
|
# -- Interactive menu --------------------------------------------------------
|
|
|
|
interactive_menu() {
|
|
while true; do
|
|
echo
|
|
bold "Pivot Forecast — Management"
|
|
echo " 1) deploy pull + install + build + restart"
|
|
echo " 2) start start server"
|
|
echo " 3) stop stop server"
|
|
echo " 4) restart restart server"
|
|
echo " 5) status service + DB + git info"
|
|
echo " 6) logs tail journald logs"
|
|
echo " 7) db-setup apply setup_sql/01_schema.sql"
|
|
echo " 8) config set DB connection + app PORT"
|
|
echo " 9) install-service create systemd unit file"
|
|
echo " 10) uninstall-service remove systemd unit file"
|
|
echo " 11) add-user create a login account"
|
|
echo " 12) passwd change an account password"
|
|
echo " 13) list-users show accounts"
|
|
echo " 14) disable-user deactivate an account and sign it out"
|
|
echo " 15) enable-user reactivate an account"
|
|
echo " q) quit"
|
|
echo
|
|
read -rp " Choice: " choice
|
|
case "$choice" in
|
|
1|deploy) cmd_deploy ;;
|
|
2|start) cmd_start ;;
|
|
3|stop) cmd_stop ;;
|
|
4|restart) cmd_restart ;;
|
|
5|status) cmd_status ;;
|
|
6|logs) cmd_logs ;;
|
|
7|db-setup) cmd_db_setup ;;
|
|
8|config) cmd_config ;;
|
|
9|install-service) cmd_install_service ;;
|
|
10|uninstall-service) cmd_uninstall_service ;;
|
|
11|add-user) cmd_add_user ;;
|
|
12|passwd) cmd_passwd ;;
|
|
13|list-users) cmd_list_users ;;
|
|
14|disable-user) cmd_disable_user ;;
|
|
15|enable-user) cmd_enable_user ;;
|
|
q|Q|quit|exit) echo "Bye."; exit 0 ;;
|
|
*) warn "Unknown option: $choice" ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# -- Dispatch ----------------------------------------------------------------
|
|
|
|
case "${1:-}" in
|
|
deploy) cmd_deploy ;;
|
|
start) cmd_start ;;
|
|
stop) cmd_stop ;;
|
|
restart) cmd_restart ;;
|
|
status) cmd_status ;;
|
|
logs) cmd_logs ;;
|
|
db-setup) cmd_db_setup ;;
|
|
config) cmd_config ;;
|
|
install-service) cmd_install_service ;;
|
|
uninstall-service) cmd_uninstall_service ;;
|
|
add-user) cmd_add_user ;;
|
|
passwd) cmd_passwd ;;
|
|
list-users) cmd_list_users ;;
|
|
disable-user) cmd_disable_user "${2:-}" ;;
|
|
enable-user) cmd_enable_user "${2:-}" ;;
|
|
"") interactive_menu ;;
|
|
*) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service add-user passwd list-users disable-user enable-user" ;;
|
|
esac
|