install script

This commit is contained in:
nnduc
2026-10-24 14:34:00 +07:00
parent 4fe046de58
commit 84e9594284
4 changed files with 358 additions and 6 deletions
+1
View File
@@ -1 +1,2 @@
/target
/dist
+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh
# Build a release tarball for Debian servers, in a Debian container so the
# binary links against Debian's glibc (a binary built on a newer distribution
# does not start on an older glibc).
#
# ./build-release.sh -> dist/movim-<version>-<debian>.tar.gz
#
# Copy it to the server, then: tar xzf movim-*.tar.gz && cd movim-* && ./install.sh
#
# Overridable: DEBIAN (default bookworm: Debian 12, also runs on 13 trixie),
# RUST_IMAGE (default rust:1-$DEBIAN), DOCKER (default docker; podman works).
set -eu
DEBIAN=${DEBIAN:-bookworm}
RUST_IMAGE=${RUST_IMAGE:-rust:1-$DEBIAN}
DOCKER=${DOCKER:-docker}
die() { echo "error: $*" >&2; exit 1; }
cd "$(dirname "$0")"
[ -f Cargo.toml ] || die "run this from the movim-rs repository"
command -v "$DOCKER" >/dev/null || die "$DOCKER not found"
"$DOCKER" info >/dev/null 2>&1 || die "cannot reach $DOCKER; start it (sudo systemctl start docker) and make sure you may use it (docker group)"
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
name="movim-$version-$DEBIAN"
# Separate target and cargo cache per image: incremental rebuilds stay fast and
# never mix with host builds in target/release.
cache="target/docker-$DEBIAN"
mkdir -p "$cache/cargo"
echo "==> Building $name in $RUST_IMAGE"
"$DOCKER" run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD:/src" -w /src \
-e CARGO_HOME="/src/$cache/cargo" \
-e CARGO_TARGET_DIR="/src/$cache" \
"$RUST_IMAGE" \
sh -c 'rustc --version && cargo build --release --locked'
echo "==> Packing dist/$name.tar.gz"
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir "$stage/$name"
cp "$cache/release/movim" install.sh "$stage/$name/"
cp -R frontend templates "$stage/$name/"
mkdir -p dist
tar -C "$stage" -czf "dist/$name.tar.gz" "$name"
echo
echo "dist/$name.tar.gz ($(du -h "dist/$name.tar.gz" | cut -f1))"
echo "On the server: tar xzf $name.tar.gz && cd $name && ./install.sh"
Executable
+286
View File
@@ -0,0 +1,286 @@
#!/bin/sh
# Install Movim as a systemd service (not enabled or started).
#
# From a release tarball (see build-release.sh), installs the prebuilt binary:
# tar xzf movim-*.tar.gz && cd movim-* && ./install.sh
# From the repository, compiles first (as a normal user, not root):
# ./install.sh
# Privileged steps go through sudo when not run as root.
#
# Overridable: MOVIM_USER (default movim, created if missing), PREFIX
# (default /opt/movim), DATA_DIR (default /var/lib/movim), CONF_DIR
# (default /etc/movim), XMPP_DOMAIN and WEB_HOST (asked when run in a
# terminal and the configuration does not exist yet).
set -eu
MOVIM_USER=${MOVIM_USER:-movim}
PREFIX=${PREFIX:-/opt/movim}
DATA_DIR=${DATA_DIR:-/var/lib/movim}
CONF_DIR=${CONF_DIR:-/etc/movim}
XMPP_DOMAIN=${XMPP_DOMAIN:-}
WEB_HOST=${WEB_HOST:-}
UNIT=/etc/systemd/system/movim.service
MIN_RUSTC=1.94
die() { echo "error: $*" >&2; exit 1; }
valid_host() {
printf '%s' "$1" | grep -Eq '^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}$'
}
# ask VAR "question" default: prompt on the terminal until a valid hostname.
ask() {
eval "current=\${$1}"
[ -z "$current" ] || { valid_host "$current" || die "$1=$current is not a valid domain name"; return 0; }
if [ ! -t 0 ]; then
echo " no terminal, $1 defaults to $3 (set $1=... to choose)"
eval "$1=\$3"
return 0
fi
while :; do
printf '%s [%s]: ' "$2" "$3"
read -r answer || die "aborted"
answer=${answer:-$3}
if valid_host "$answer"; then
eval "$1=\$answer"
return 0
fi
echo " not a valid domain name: $answer"
done
}
cd "$(dirname "$0")"
[ -d frontend ] && [ -d templates ] || die "run this from the movim-rs repository or an extracted release"
command -v systemctl >/dev/null || die "systemd is required"
if [ "$(id -u)" -eq 0 ]; then
sudo() { "$@"; }
else
command -v sudo >/dev/null || die "sudo is required (or run as root)"
fi
if [ -f Cargo.toml ]; then
[ "$(id -u)" -ne 0 ] || die "do not compile as root; run as a normal user, privileged steps use sudo"
BIN=target/release/movim
elif [ -x movim ]; then
BIN=./movim
echo "==> Prebuilt binary: $(file -b movim 2>/dev/null || echo movim)"
else
die "no Cargo.toml and no prebuilt ./movim"
fi
# --- Questions (before the build, so nobody has to wait for it) -----------
if [ ! -e "$CONF_DIR/movim.env" ]; then
ask XMPP_DOMAIN "XMPP domain users log in with (MOVIM_DOMAIN)" example.org
fi
if [ -d /etc/nginx/sites-available ] && [ ! -e /etc/nginx/sites-available/movim ]; then
default_web=movim.${XMPP_DOMAIN:-example.org}
ask WEB_HOST "Web hostname serving Movim (nginx server_name)" "$default_web"
fi
# --- Build -----------------------------------------------------------------
build() {
rustup_help="Distribution packages (e.g. Debian's rustc) are too old. Install a current
toolchain for your user with rustup, it does not conflict with apt's rustc:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
then run this script again."
# Prefer a rustup toolchain over the distribution's cargo, even if
# ~/.cargo/bin is not in PATH (non-login shell, Debian's cargo first).
[ ! -x "$HOME/.cargo/bin/cargo" ] || PATH="$HOME/.cargo/bin:$PATH"
command -v cargo >/dev/null || die "cargo not found, need Rust >= $MIN_RUSTC.
$rustup_help"
rustc_version=$(rustc --version | cut -d' ' -f2)
lowest=$(printf '%s\n%s\n' "$MIN_RUSTC" "$rustc_version" | sort -V | head -n1)
if [ "$lowest" != "$MIN_RUSTC" ]; then
if command -v rustup >/dev/null; then
die "rustc $rustc_version is too old, need >= $MIN_RUSTC: rustup update stable && rustup default stable"
fi
die "rustc $rustc_version ($(command -v rustc)) is too old, need >= $MIN_RUSTC.
$rustup_help"
fi
# Linker and C compiler (bundled SQLite, crypto libraries).
command -v cc >/dev/null || die "no C compiler; on Debian: sudo apt install build-essential cmake"
echo "==> Building (rustc $rustc_version)"
cargo build --release --locked
}
[ ! -f Cargo.toml ] || build
# --- Install ---------------------------------------------------------------
echo "==> Installing to $PREFIX (sudo)"
if ! id "$MOVIM_USER" >/dev/null 2>&1; then
nologin=$(command -v nologin || echo /usr/sbin/nologin)
echo " creating system user $MOVIM_USER"
sudo useradd --system --user-group --home-dir "$DATA_DIR" --no-create-home \
--shell "$nologin" "$MOVIM_USER"
fi
group=$(id -gn "$MOVIM_USER")
# Code and assets are owned by root and read-only for the service.
sudo install -d -m 0755 -o root -g root "$PREFIX"
sudo install -m 0755 -o root -g root "$BIN" "$PREFIX/movim.new"
for d in frontend templates; do
sudo rm -rf "$PREFIX/$d.new"
sudo cp -R "$d" "$PREFIX/$d.new"
sudo chown -R root:root "$PREFIX/$d.new"
sudo chmod -R u=rwX,go=rX "$PREFIX/$d.new"
done
# Swap in the new files only once everything is copied.
sudo mv -f "$PREFIX/movim.new" "$PREFIX/movim"
for d in frontend templates; do
sudo rm -rf "$PREFIX/$d.old"
[ ! -e "$PREFIX/$d" ] || sudo mv "$PREFIX/$d" "$PREFIX/$d.old"
sudo mv "$PREFIX/$d.new" "$PREFIX/$d"
sudo rm -rf "$PREFIX/$d.old"
done
# Database, VAPID key: only the service user can read them.
sudo install -d -m 0750 -o "$MOVIM_USER" -g "$group" "$DATA_DIR"
# Configuration: never overwrite an existing one.
sudo install -d -m 0755 -o root -g root "$CONF_DIR"
if [ ! -e "$CONF_DIR/movim.env" ]; then
echo " writing $CONF_DIR/movim.env"
sudo tee "$CONF_DIR/movim.env" >/dev/null <<EOF
# Movim configuration, read by $UNIT
MOVIM_ROOT=$PREFIX
MOVIM_DATA_DIR=$DATA_DIR
# Listen on localhost only, behind a TLS reverse proxy (WebSocket on /ws/).
MOVIM_BIND=127.0.0.1:8080
# XMPP domain proposed on the login page.
MOVIM_DOMAIN=$XMPP_DOMAIN
# Comma separated XMPP domains allowed to log in (empty: all).
#MOVIM_XMPP_WHITELIST=example.org
#MOVIM_DISABLE_REGISTRATION=1
#MOVIM_MAX_SESSIONS=0
#MOVIM_LOCALE=en
#MOVIM_SESSION_TIMEOUT_HOURS=168
#MOVIM_BANNER=
#MOVIM_INFO=
EOF
sudo chown root:"$group" "$CONF_DIR/movim.env"
sudo chmod 0640 "$CONF_DIR/movim.env"
else
echo " keeping existing $CONF_DIR/movim.env"
fi
bind=$(sudo sed -n 's/^MOVIM_BIND=//p' "$CONF_DIR/movim.env")
bind=${bind:-127.0.0.1:8080}
# nginx site (Debian layout): written to sites-available, never overwritten,
# never linked into sites-enabled.
nginx_site=
if [ -d /etc/nginx/sites-available ]; then
nginx_site=/etc/nginx/sites-available/movim
if [ ! -e "$nginx_site" ]; then
echo " writing $nginx_site"
sudo tee "$nginx_site" >/dev/null <<EOF
# Movim behind nginx. Get the certificate first, e.g.
# sudo certbot certonly --nginx -d $WEB_HOST
# or point ssl_certificate* to your own.
server {
listen 80;
listen [::]:80;
server_name $WEB_HOST;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name $WEB_HOST;
ssl_certificate /etc/letsencrypt/live/$WEB_HOST/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$WEB_HOST/privkey.pem;
# HTTP file uploads go through Movim, match your XMPP upload limit.
client_max_body_size 100m;
location / {
proxy_pass http://$bind;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
}
location /ws {
proxy_pass http://$bind;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
}
EOF
else
echo " keeping existing $nginx_site"
fi
fi
echo " writing $UNIT"
sudo tee "$UNIT" >/dev/null <<EOF
[Unit]
Description=Movim XMPP web client
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$MOVIM_USER
Group=$group
EnvironmentFile=$CONF_DIR/movim.env
ExecStart=$PREFIX/movim
Restart=on-failure
RestartSec=5
# Sandboxing: the service only writes to its data directory.
ReadWritePaths=$DATA_DIR
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
NoNewPrivileges=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
CapabilityBoundingSet=
UMask=0027
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
echo
echo "Installed. Not enabled or started; as admin:"
echo " 1. check $CONF_DIR/movim.env"
echo " 2. sudo systemctl enable --now movim"
if [ -n "$nginx_site" ]; then
echo " 3. get a certificate for the nginx site ($nginx_site), then:"
echo " sudo ln -s $nginx_site /etc/nginx/sites-enabled/movim"
echo " sudo nginx -t && sudo systemctl reload nginx"
else
echo " 3. put a TLS reverse proxy in front of $bind (WebSocket on /ws/)"
fi
if systemctl is-active --quiet movim; then
echo
echo "movim is running the previous version: sudo systemctl restart movim"
fi
+19 -6
View File
@@ -3,7 +3,10 @@
use anyhow::Result;
use serde::Serialize;
use sqlx::{sqlite::SqlitePoolOptions, AssertSqlSafe, FromRow, SqlitePool};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::{AssertSqlSafe, FromRow, SqlitePool};
use std::str::FromStr;
use std::time::Duration;
pub type Db = SqlitePool;
@@ -153,8 +156,14 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
"#;
pub async fn connect(url: &str) -> Result<Db> {
let pool = SqlitePoolOptions::new().max_connections(8).connect(url).await?;
sqlx::query("PRAGMA journal_mode=WAL").execute(&pool).await?;
// WAL: readers never block the writer. synchronous=NORMAL is safe in WAL
// (no corruption, a power loss can only drop the last commits) and avoids an
// fsync per message. Writers queue on the busy timeout instead of failing.
let options = SqliteConnectOptions::from_str(url)?
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.busy_timeout(Duration::from_secs(10));
let pool = SqlitePoolOptions::new().max_connections(8).connect_with(options).await?;
for statement in SCHEMA.split(';') {
let statement = statement.trim();
if !statement.is_empty() {
@@ -840,15 +849,17 @@ pub async fn reactions_for(db: &Db, mids: &[i64]) -> Result<Vec<Reaction>> {
}
pub async fn reactions_replace(db: &Db, mid: i64, jidfrom: &str, emojis: &[String]) -> Result<()> {
sqlx::query("DELETE FROM reactions WHERE message_mid = ? AND jidfrom = ?").bind(mid).bind(jidfrom).execute(db).await?;
let mut tx = db.begin().await?;
sqlx::query("DELETE FROM reactions WHERE message_mid = ? AND jidfrom = ?").bind(mid).bind(jidfrom).execute(&mut *tx).await?;
for emoji in emojis {
sqlx::query("INSERT OR IGNORE INTO reactions (message_mid, jidfrom, emoji) VALUES (?, ?, ?)")
.bind(mid)
.bind(jidfrom)
.bind(emoji)
.execute(db)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
@@ -1014,6 +1025,7 @@ pub async fn messages_search(db: &Db, user: &str, jid: &str, muc: bool, keywords
/// Forget everything stored for an account
pub async fn user_delete_data(db: &Db, user: &str) -> Result<()> {
let mut tx = db.begin().await?;
for sql in [
"DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ?)",
"DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ?)",
@@ -1025,8 +1037,9 @@ pub async fn user_delete_data(db: &Db, user: &str) -> Result<()> {
"DELETE FROM push_subscriptions WHERE user_id = ?",
"DELETE FROM users WHERE id = ?",
] {
sqlx::query(sql).bind(user).execute(db).await?;
sqlx::query(sql).bind(user).execute(&mut *tx).await?;
}
tx.commit().await?;
Ok(())
}