feat: scaffold newsfeed — user-controlled news feed
A self-hosted feed where the user owns the ranking: per-source and signed per-interest weights, decayed by recency, via a transparent deterministic scorer. Content is sourced algorithmically (worker RSS/Atom polling) and agentically (per-user API tokens POSTing candidates to the ingest endpoint). Single-user today, multi-user by construction (every row keyed on user_id). Rust cargo workspace + Vite/React/SWC/TS SPA: - newsfeed-entities: DTOs (ts-rs bindings -> web/src/api/bindings) - newsfeed-core: ranking, auth primitives, ingest, data-access ports - newsfeed-data: SQLite adapters (sqlx, runtime queries) - newsfeed-api: axum REST/JSON daemon - newsfeed-worker: RSS polling + rescoring loop - web: responsive, mobile-first SPA (React Query, generated types) Deploy (Gitea Actions, build static musl + SPA, rsync as gitea_ci): api+worker -> slartibartfast, SPA -> oolon (nginx serves + proxies /v1). Deliberate deviations from house conventions (documented in CLAUDE.md/readme): - SQLite instead of Postgres; api+worker co-locate sharing one DB file. - Runtime sqlx queries instead of query! macros (SQLite dynamic typing; keeps CI database-free). Verified end-to-end: auth, token ingest, interest-weighted ranking, signals, pagination (curl + browser); cargo fmt/clippy -D/test and pnpm build/lint pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
This commit is contained in:
213
script/infra-setup.sh
Executable file
213
script/infra-setup.sh
Executable file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# One-time host provisioning for newsfeed's CI-driven deploy (architecture
|
||||
# deployment-gitea-actions.md §2). Run by the operator from a workstation with full sudo
|
||||
# and mesh ssh access — NOT by the CI runner. Idempotent; skips unreachable hosts.
|
||||
#
|
||||
# It provisions, per host:
|
||||
# * the `gitea_ci` deploy user + the runner's authorized_key + systemd-journal group
|
||||
# * a scoped /etc/sudoers.d/newsfeed_gitea_ci drop-in (visudo-verified)
|
||||
# and per role:
|
||||
# * API host (slartibartfast): the `newsfeed` service account, /etc/newsfeed +
|
||||
# /var/lib/newsfeed, SELinux port label for 8081, firewalld service
|
||||
# * WEB host (oolon): /var/www/newsfeed webroot, the internal `newsfeed.internal` TLS
|
||||
# cert (lair provisioner) + step@newsfeed renewal, and the nginx vhost
|
||||
#
|
||||
# Usage: ./script/infra-setup.sh [--dry-run]
|
||||
set -euo pipefail
|
||||
|
||||
# --- infra truth (edit here if the topology changes) -------------------------------
|
||||
API_HOST="slartibartfast.kosherinata.internal" # newsfeed-api + newsfeed-worker
|
||||
WEB_HOST="oolon.kosherinata.internal" # nginx: SPA + API reverse proxy
|
||||
API_PORT="8081"
|
||||
CERT_NAME="newsfeed" # dot-free; serves ${CERT_NAME}.internal
|
||||
RUNNER_PUBKEY="${HOME}/.ssh/id_gitea_ci.pub" # runner key distributed to gitea_ci
|
||||
PROVISIONER_PW="${HOME}/.step/secrets/provisioner"
|
||||
ROOT_CA="/etc/pki/ca-trust/source/anchors/root-internal.pem"
|
||||
CA_URL="https://ca.internal"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DRY_RUN=0
|
||||
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
|
||||
|
||||
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mWARN\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
run_remote() {
|
||||
# run_remote <host> <script>
|
||||
local host="$1" script="$2"
|
||||
if [[ "$DRY_RUN" == 1 ]]; then
|
||||
printf -- '--- would run on %s ---\n%s\n' "$host" "$script"
|
||||
return 0
|
||||
fi
|
||||
ssh -o StrictHostKeyChecking=accept-new "$host" "sudo bash -euo pipefail -s" <<<"$script"
|
||||
}
|
||||
|
||||
reachable() {
|
||||
ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 "$1" true 2>/dev/null
|
||||
}
|
||||
|
||||
require_files() {
|
||||
[[ -f "$RUNNER_PUBKEY" ]] || { warn "runner pubkey $RUNNER_PUBKEY missing (ssh-keygen -t ed25519 -f ~/.ssh/id_gitea_ci)"; exit 1; }
|
||||
}
|
||||
|
||||
# --- gitea_ci user, key, journal group, sudoers (both hosts) ------------------------
|
||||
provision_gitea_ci() {
|
||||
local host="$1" sudoers="$2"
|
||||
local pubkey; pubkey="$(cat "$RUNNER_PUBKEY")"
|
||||
info "[$host] provisioning gitea_ci deploy user"
|
||||
run_remote "$host" "
|
||||
id gitea_ci >/dev/null 2>&1 || useradd --system --create-home --home-dir /var/lib/gitea_ci --shell /usr/sbin/nologin gitea_ci
|
||||
usermod -aG systemd-journal gitea_ci
|
||||
install -d -m 0700 -o gitea_ci -g gitea_ci /var/lib/gitea_ci/.ssh
|
||||
grep -qF '${pubkey}' /var/lib/gitea_ci/.ssh/authorized_keys 2>/dev/null || echo '${pubkey}' >> /var/lib/gitea_ci/.ssh/authorized_keys
|
||||
chown gitea_ci:gitea_ci /var/lib/gitea_ci/.ssh/authorized_keys
|
||||
chmod 0600 /var/lib/gitea_ci/.ssh/authorized_keys
|
||||
umask 022
|
||||
cat > /etc/sudoers.d/newsfeed_gitea_ci <<'SUDOERS'
|
||||
${sudoers}
|
||||
SUDOERS
|
||||
chmod 0440 /etc/sudoers.d/newsfeed_gitea_ci
|
||||
visudo -cf /etc/sudoers.d/newsfeed_gitea_ci
|
||||
"
|
||||
}
|
||||
|
||||
API_SUDOERS='# Scoped deploy permissions for newsfeed api+worker on this host.
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/newsfeed-api
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/newsfeed-worker
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/newsfeed/config.toml
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/newsfeed/worker.toml
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart newsfeed-api.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart newsfeed-worker.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl is-active newsfeed-api.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/newsfeed-api /usr/local/bin/newsfeed-worker /etc/newsfeed /var/lib/newsfeed'
|
||||
|
||||
WEB_SUDOERS='# Scoped deploy permissions for the newsfeed SPA on the oolon proxy.
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/newsfeed/
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /var/www/newsfeed
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/nginx -t
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx.service'
|
||||
|
||||
# --- API host: service account, dirs, SELinux port, firewalld -----------------------
|
||||
provision_api_host() {
|
||||
local host="$API_HOST"
|
||||
if ! reachable "$host"; then warn "[$host] unreachable, skipping"; return 0; fi
|
||||
provision_gitea_ci "$host" "$API_SUDOERS"
|
||||
|
||||
info "[$host] service account, directories, SELinux, firewalld"
|
||||
local sysusers firewalld
|
||||
sysusers="$(cat "$REPO_ROOT/asset/systemd/newsfeed.sysusers.conf")"
|
||||
firewalld="$(cat "$REPO_ROOT/asset/firewalld/newsfeed-api.xml")"
|
||||
run_remote "$host" "
|
||||
# service account
|
||||
cat > /etc/sysusers.d/newsfeed.conf <<'SYSU'
|
||||
${sysusers}
|
||||
SYSU
|
||||
systemd-sysusers /etc/sysusers.d/newsfeed.conf
|
||||
|
||||
# directories (config: root-owned, readable by service; state: service-owned)
|
||||
install -d -m 0750 -o root -g newsfeed /etc/newsfeed
|
||||
install -d -m 0750 -o newsfeed -g newsfeed /var/lib/newsfeed
|
||||
install -d -m 0755 -o root -g root /usr/local/bin
|
||||
restorecon -R /etc/newsfeed /var/lib/newsfeed
|
||||
|
||||
# SELinux: label the API port so the daemon may bind it
|
||||
semanage port -l | grep -qE '^http_port_t.*[[:space:]]${API_PORT}([,[:space:]]|\$)' || semanage port -a -t http_port_t -p tcp ${API_PORT}
|
||||
|
||||
# firewalld named service (mesh-reachable API port)
|
||||
cat > /etc/firewalld/services/newsfeed-api.xml <<'FWD'
|
||||
${firewalld}
|
||||
FWD
|
||||
firewall-cmd --reload
|
||||
zone=\$(firewall-cmd --get-default-zone)
|
||||
firewall-cmd --permanent --zone=\$zone --query-service=newsfeed-api >/dev/null 2>&1 || firewall-cmd --permanent --zone=\$zone --add-service=newsfeed-api
|
||||
firewall-cmd --zone=\$zone --query-service=newsfeed-api >/dev/null 2>&1 || firewall-cmd --zone=\$zone --add-service=newsfeed-api
|
||||
"
|
||||
|
||||
info "[$host] installing systemd units"
|
||||
for unit in newsfeed-api.service newsfeed-worker.service; do
|
||||
local content; content="$(cat "$REPO_ROOT/asset/systemd/$unit")"
|
||||
run_remote "$host" "cat > /etc/systemd/system/$unit <<'UNIT'
|
||||
${content}
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable $unit"
|
||||
done
|
||||
}
|
||||
|
||||
# --- WEB host (oolon): webroot, internal cert, nginx vhost --------------------------
|
||||
provision_web_host() {
|
||||
local host="$WEB_HOST"
|
||||
if ! reachable "$host"; then warn "[$host] unreachable, skipping"; return 0; fi
|
||||
provision_gitea_ci "$host" "$WEB_SUDOERS"
|
||||
|
||||
info "[$host] webroot"
|
||||
run_remote "$host" "
|
||||
install -d -m 0755 -o nginx -g nginx /var/www/newsfeed
|
||||
# placeholder so nginx -t passes before the first SPA deploy
|
||||
[ -f /var/www/newsfeed/index.html ] || echo '<!doctype html><title>newsfeed</title>' > /var/www/newsfeed/index.html
|
||||
restorecon -R /var/www/newsfeed
|
||||
"
|
||||
|
||||
mint_internal_cert "$host"
|
||||
|
||||
info "[$host] nginx vhost (newsfeed.internal)"
|
||||
local vhost; vhost="$(cat "$REPO_ROOT/asset/nginx/newsfeed.internal.conf")"
|
||||
run_remote "$host" "
|
||||
install -d -m 0755 /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||||
cat > /etc/nginx/sites-available/newsfeed.internal.conf <<'VHOST'
|
||||
${vhost}
|
||||
VHOST
|
||||
ln -sfn ../sites-available/newsfeed.internal.conf /etc/nginx/sites-enabled/newsfeed.internal.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
"
|
||||
info "[$host] to publish on the WAN, set server_name + a Let's Encrypt cert in"
|
||||
info " asset/nginx/newsfeed.public.conf and symlink it into sites-enabled."
|
||||
}
|
||||
|
||||
# Mint the first internal cert (idempotent) and enable renewal (internal-tls.md §4).
|
||||
mint_internal_cert() {
|
||||
local host="$1"
|
||||
local cert="/etc/nginx/tls/cert/${CERT_NAME}.internal.pem"
|
||||
local key="/etc/nginx/tls/key/${CERT_NAME}.internal.pem"
|
||||
|
||||
if [[ "$DRY_RUN" == 1 ]]; then info "[$host] would mint ${CERT_NAME}.internal cert"; return 0; fi
|
||||
[[ -f "$PROVISIONER_PW" ]] || { warn "[$host] provisioner password $PROVISIONER_PW missing; skipping cert mint"; return 0; }
|
||||
|
||||
local state
|
||||
state="$(ssh "$host" "[ -f $cert ] && step certificate verify $cert --roots $ROOT_CA >/dev/null 2>&1 && echo valid || echo missing")"
|
||||
if [[ "$state" != valid ]]; then
|
||||
info "[$host] minting ${CERT_NAME}.internal cert via lair provisioner"
|
||||
rsync -az --rsync-path='sudo rsync' --chmod=0600 "$PROVISIONER_PW" "$host:/tmp/${CERT_NAME}-provisioner"
|
||||
run_remote "$host" "
|
||||
mkdir -p /etc/nginx/tls/cert /etc/nginx/tls/key
|
||||
rc=0
|
||||
step ca certificate --force \
|
||||
--provisioner lair \
|
||||
--provisioner-password-file /tmp/${CERT_NAME}-provisioner \
|
||||
--ca-url $CA_URL --root $ROOT_CA \
|
||||
--san ${CERT_NAME}.internal \
|
||||
${CERT_NAME}.internal $cert $key || rc=\$?
|
||||
rm -f /tmp/${CERT_NAME}-provisioner
|
||||
[ \$rc -eq 0 ] || { echo 'cert mint failed' >&2; exit \$rc; }
|
||||
chown root:root $cert $key
|
||||
chmod 644 $cert; chmod 640 $key
|
||||
setfacl -m u:nginx:r $key
|
||||
"
|
||||
else
|
||||
info "[$host] ${CERT_NAME}.internal cert already valid"
|
||||
fi
|
||||
run_remote "$host" "systemctl enable --now step@${CERT_NAME}.timer"
|
||||
}
|
||||
|
||||
main() {
|
||||
require_files
|
||||
info "newsfeed infra-setup (api=$API_HOST web=$WEB_HOST)$([[ $DRY_RUN == 1 ]] && echo ' [dry-run]')"
|
||||
provision_api_host
|
||||
provision_web_host
|
||||
info "done"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user