Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env sh
# ferrite — install script
# Downloads pre-built Linux binaries from GitHub Releases and sets up the system service.
#
# Usage (Linux, run as root):
#   curl -fsSL https://raw.githubusercontent.com/koroblabs/ferrite-server/main/install.sh | sudo sh
#
# Or download and inspect first:
#   curl -fsSL https://raw.githubusercontent.com/koroblabs/ferrite-server/main/install.sh -o install.sh
#   sudo sh install.sh

set -eu

# ── Config ────────────────────────────────────────────────────────────────────

GITHUB_BASE_URL="${GITHUB_BASE_URL:-https://github.com}"
GITHUB_API_BASE="${GITHUB_API_BASE:-https://api.github.com}"
GITHUB_OWNER="${GITHUB_OWNER:-koroblabs}"
GITHUB_REPO_SERVER="${GITHUB_REPO_SERVER:-ferrite-server}"
GITHUB_REPO_WEB="${GITHUB_REPO_WEB:-ferrite-web}"

BIN_DIR="/usr/local/bin"
SERVICE_UPDATE_BIN_DIR="/usr/local/lib/ferrite/bin"
CONFIG_DIR="/etc/ferrite"
DATA_DIR="/var/lib/ferrite"
WEB_PARENT="/usr/share/ferrite"
WEB_DIR="${WEB_PARENT}/web"
SERVICE_USER="ferrite"
SERVICE_GROUP="ferrite"
SYSTEMD_SERVICE="/etc/systemd/system/ferrite.service"
OPENRC_SERVICE="/etc/init.d/ferrite"

# ── Colors ────────────────────────────────────────────────────────────────────

if [ -t 1 ]; then
    RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
    BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
else
    RED=''; GREEN=''; YELLOW=''; BLUE=''; BOLD=''; NC=''
fi

info()  { printf "${BLUE}→${NC} %s\n" "$*"; }
ok()    { printf "${GREEN}✓${NC} %s\n" "$*"; }
warn()  { printf "${YELLOW}!${NC} %s\n" "$*"; }
die()   { printf "${RED}✗${NC} %s\n" "$*" >&2; exit 1; }
bold()  { printf "${BOLD}%s${NC}\n" "$*"; }

# ── Checks ────────────────────────────────────────────────────────────────────

[ "$(id -u)" -eq 0 ] || die "Run as root: sudo sh install.sh"

for cmd in curl tar; do
    command -v "$cmd" >/dev/null 2>&1 || die "Required tool not found: $cmd"
done

# ── Platform detection ────────────────────────────────────────────────────────

OS=$(uname -s | tr '[:upper:]' '[:lower:]')

# Asset names use Rust target triples — must match current_platform_target() in updater/server.rs.
detect_asset_pattern() {
    case "$(uname -s)-$(uname -m)" in
        Linux-x86_64)           printf "x86_64-unknown-linux-musl"  ;;
        Linux-aarch64)          printf "aarch64-unknown-linux-musl" ;;
        *) die "Unsupported platform: $(uname -s) $(uname -m). The installer currently supports Linux x86_64/arm64 only." ;;
    esac
}
ASSET_PATTERN=$(detect_asset_pattern)

# Human-friendly name for display only.
detect_platform_display() {
    case "$(uname -s)-$(uname -m)" in
        Linux-x86_64)           printf "linux-x86_64"  ;;
        Linux-aarch64)          printf "linux-arm64"   ;;
        *)                      printf "unknown"        ;;
    esac
}
PLATFORM=$(detect_platform_display)

# Detect Alpine Linux.
IS_ALPINE=false
[ -f /etc/alpine-release ] && IS_ALPINE=true

# Detect init system.
detect_init_system() {
    if [ -d /run/systemd/system ] || (command -v systemctl >/dev/null 2>&1 && systemctl --version >/dev/null 2>&1); then
        printf "systemd"
    elif command -v rc-service >/dev/null 2>&1; then
        printf "openrc"
    else
        printf "none"
    fi
}
INIT_SYSTEM=$(detect_init_system)

if [ "$OS" = "linux" ] && { [ "$INIT_SYSTEM" = "systemd" ] || [ "$INIT_SYSTEM" = "openrc" ]; }; then
    SERVICE_BIN_DIR="$SERVICE_UPDATE_BIN_DIR"
else
    SERVICE_BIN_DIR="$BIN_DIR"
fi
SERVICE_BIN="${SERVICE_BIN_DIR}/ferrite"
PUBLIC_BIN="${BIN_DIR}/ferrite"

# ── Release helpers ───────────────────────────────────────────────────────────

release_token() {
    if [ -n "${FERRITE_RELEASE_TOKEN:-}" ]; then
        printf '%s' "$FERRITE_RELEASE_TOKEN"
    elif [ -n "${GITHUB_TOKEN:-}" ]; then
        printf '%s' "$GITHUB_TOKEN"
    elif [ -n "${GITEA_TOKEN:-}" ]; then
        printf '%s' "$GITEA_TOKEN"
    fi
}

release_latest() {
    token=$(release_token)
    if [ -n "$token" ]; then
        curl -fsSL \
            -H "Accept: application/json" \
            -H "Authorization: Bearer ${token}" \
            "${GITHUB_API_BASE}/repos/${GITHUB_OWNER}/$1/releases/latest"
    else
        curl -fsSL \
            -H "Accept: application/json" \
            "${GITHUB_API_BASE}/repos/${GITHUB_OWNER}/$1/releases/latest"
    fi
}

release_asset_url() {
    release_asset_field "$1" "$2" url
}

release_asset_digest() {
    release_asset_field "$1" "$2" digest
}

release_asset_field() {
    # Use awk over GitHub's release JSON to avoid a jq dependency.
    printf '%s\n' "$1" | tr "," "\n" | awk -v pattern="$2" -v want="$3" '
        function json_value(line) {
            sub(/^[^:]*:[[:space:]]*"/, "", line)
            sub(/".*$/, "", line)
            return line
        }

        function maybe_emit() {
            target = name " " url
            if (url != "" && target ~ pattern && name !~ /\.sha256$/ && url !~ /\.sha256$/) {
                if (want == "url") {
                    print url
                    exit
                }
                if (want == "digest" && digest != "") {
                    print digest
                    exit
                }
            }
        }

        /"name"[[:space:]]*:/ {
            name = json_value($0)
            url = ""
            digest = ""
        }

        /"browser_download_url"[[:space:]]*:/ {
            url = json_value($0)
            maybe_emit()
        }

        /"digest"[[:space:]]*:/ {
            digest = json_value($0)
            maybe_emit()
        }
    '
}

release_tag() {
    printf '%s' "$1" \
        | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
        | head -1
}

release_version() {
    printf '%s' "$1" | sed 's/^v//'
}

download_asset() {
    token=$(release_token)
    if [ -n "$token" ]; then
        curl -fL --progress-bar -H "Authorization: Bearer ${token}" "$1" -o "$2"
    else
        curl -fL --progress-bar "$1" -o "$2"
    fi
}

normalize_sha256() {
    printf '%s\n' "$1" | grep -Eo '[A-Fa-f0-9]{64}' | head -1 | tr '[:upper:]' '[:lower:]'
}

sha256_file() {
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "$1" | awk '{print $1}'
    elif command -v shasum >/dev/null 2>&1; then
        shasum -a 256 "$1" | awk '{print $1}'
    elif command -v openssl >/dev/null 2>&1; then
        openssl dgst -sha256 "$1" | awk '{print $NF}'
    else
        return 1
    fi
}

verify_download_sha256() {
    file="$1"
    expected="$2"
    label="$3"
    [ -n "$expected" ] || return 0

    actual=$(sha256_file "$file") || {
        warn "No SHA256 tool found; skipping checksum verification for ${label}."
        return 0
    }

    [ "$actual" = "$expected" ] || die "${label} checksum mismatch: expected ${expected}, got ${actual}"
}

# ── Install server binary ─────────────────────────────────────────────────────

install_server() {
    bold "Installing ferrite server..."

    mkdir -p "$SERVICE_BIN_DIR" "$BIN_DIR"

    json=$(release_latest "$GITHUB_REPO_SERVER") \
        || die "Failed to fetch release info from GitHub. Check your network and release assets."

    version=$(release_tag "$json")
    [ -n "$version" ] || die "Could not determine release version."
    info "Latest version: ${version}"

    # Try tar.gz first (matches release.yml packaging), fall back to bare binary.
    is_archive=false
    asset_pattern="${ASSET_PATTERN}.*\\.tar\\.gz"
    url=$(release_asset_url "$json" "$asset_pattern")
    if [ -n "$url" ]; then
        is_archive=true
    else
        asset_pattern="${ASSET_PATTERN}"
        url=$(release_asset_url "$json" "$asset_pattern")
    fi

    [ -n "$url" ] || die "No release asset found for '${ASSET_PATTERN}'. The platform may not have a build yet."
    expected_sha=$(normalize_sha256 "$(release_asset_digest "$json" "$asset_pattern")")

    info "Downloading from: ${url}"
    tmp=$(mktemp)
    download_asset "$url" "$tmp"

    if [ -n "$expected_sha" ]; then
        verify_download_sha256 "$tmp" "$expected_sha" "Server archive"
    else
        warn "GitHub release did not expose a SHA256 digest for the server asset; skipping checksum verification."
    fi

    if $is_archive; then
        # Extract ferrite binary from the archive. Uses a temp dir for BusyBox tar
        # compatibility (Alpine) — avoids GNU tar-only flags like --wildcards and -O.
        tmp_dir=$(mktemp -d)
        tar -xzf "$tmp" -C "$tmp_dir"
        bin_path=$(find "$tmp_dir" -name 'ferrite' -type f | head -1)
        [ -n "$bin_path" ] || die "Could not find 'ferrite' binary in the release archive."
        install -m 755 "$bin_path" "$SERVICE_BIN"
        rm -rf "$tmp_dir" "$tmp"
    else
        install -m 755 "$tmp" "$SERVICE_BIN"
        rm -f "$tmp"
    fi

    if [ "$SERVICE_BIN" != "$PUBLIC_BIN" ]; then
        ln -sfn "$SERVICE_BIN" "$PUBLIC_BIN"
    fi

    if [ -n "$expected_sha" ]; then
        printf '%s\n' "$expected_sha" > "${SERVICE_BIN}.sha256"
        if [ "$SERVICE_BIN" != "$PUBLIC_BIN" ]; then
            ln -sfn "${SERVICE_BIN}.sha256" "${PUBLIC_BIN}.sha256"
        fi
    else
        rm -f "${SERVICE_BIN}.sha256" "${PUBLIC_BIN}.sha256"
    fi

    ok "Installed ferrite ${version} → ${SERVICE_BIN}"
    if [ "$SERVICE_BIN" != "$PUBLIC_BIN" ]; then
        ok "CLI link: ${PUBLIC_BIN} → ${SERVICE_BIN}"
    fi
}

# ── Install web UI ────────────────────────────────────────────────────────────

install_web() {
    bold "Installing ferrite web UI..."

    json=$(release_latest "$GITHUB_REPO_WEB" 2>/dev/null) || {
        warn "Could not fetch web UI release (repo may not have releases yet). Skipping."
        return 0
    }

    version=$(release_tag "$json")
    [ -n "$version" ] || { warn "No web UI release found. Skipping."; return 0; }
    info "Latest web UI version: ${version}"
    installed_version=$(release_version "$version")

    asset_pattern="\\.tar\\.gz"
    url=$(release_asset_url "$json" "$asset_pattern")
    if [ -z "$url" ]; then
        asset_pattern="\\.zip"
        url=$(release_asset_url "$json" "$asset_pattern")
    fi
    [ -n "$url" ] || { warn "No web UI asset found in release. Skipping."; return 0; }
    expected_sha=$(normalize_sha256 "$(release_asset_digest "$json" "$asset_pattern")")

    info "Downloading from: ${url}"
    tmp=$(mktemp)
    download_asset "$url" "$tmp"

    if [ -n "$expected_sha" ]; then
        verify_download_sha256 "$tmp" "$expected_sha" "Web UI archive"
    else
        warn "GitHub release did not expose a SHA256 digest for the web UI asset; skipping checksum verification."
    fi

    rm -rf "$WEB_DIR"
    mkdir -p "$WEB_DIR"

    if tar -tzf "$tmp" | head -1 | grep -q '/'; then
        tar -xzf "$tmp" -C "$WEB_DIR" --strip-components=1
    else
        tar -xzf "$tmp" -C "$WEB_DIR"
    fi
    rm -f "$tmp"

    printf '%s\n' "$installed_version" > "${WEB_DIR}.version"
    if [ -n "$expected_sha" ]; then
        printf '%s\n' "$expected_sha" > "${WEB_DIR}.sha256"
    else
        rm -f "${WEB_DIR}.sha256"
    fi

    ok "Installed web UI ${version} → ${WEB_DIR}"
}

# ── Create system user ────────────────────────────────────────────────────────

group_exists() {
    getent group "$1" >/dev/null 2>&1 || grep -q "^$1:" /etc/group 2>/dev/null
}

ensure_linux_group() {
    group_exists "$SERVICE_GROUP" && return 0

    if command -v groupadd >/dev/null 2>&1; then
        groupadd --system "$SERVICE_GROUP"
    elif command -v addgroup >/dev/null 2>&1; then
        addgroup -S "$SERVICE_GROUP" 2>/dev/null || addgroup "$SERVICE_GROUP"
    else
        die "Could not create group '${SERVICE_GROUP}': groupadd/addgroup not found."
    fi
    ok "Created system group: ${SERVICE_GROUP}"
}

ensure_linux_user_group() {
    if command -v usermod >/dev/null 2>&1; then
        usermod -g "$SERVICE_GROUP" "$SERVICE_USER" 2>/dev/null || true
    elif command -v addgroup >/dev/null 2>&1; then
        addgroup "$SERVICE_USER" "$SERVICE_GROUP" 2>/dev/null || true
    fi
}

service_owner() {
    if [ "$OS" = "linux" ]; then
        printf "%s:%s" "$SERVICE_USER" "$SERVICE_GROUP"
    else
        printf "%s" "$SERVICE_USER"
    fi
}

configured_api_port() {
    awk '
        /^\[api\]/ { in_api = 1; next }
        /^\[/ { in_api = 0 }
        in_api && /^[[:space:]]*bind_addr[[:space:]]*=/ {
            value = $0
            sub(/^[^"]*"/, "", value)
            sub(/".*$/, "", value)
            sub(/^.*:/, "", value)
            if (value ~ /^[0-9]+$/) {
                print value
                exit
            }
        }
    ' "${CONFIG_DIR}/config.toml"
}

create_user() {
    if [ "$OS" = "linux" ]; then
        ensure_linux_group

        if id "$SERVICE_USER" >/dev/null 2>&1; then
            ensure_linux_user_group
            ok "System user '${SERVICE_USER}' already exists"
            return 0
        fi
        if $IS_ALPINE; then
            # Alpine uses BusyBox adduser (no useradd).
            adduser -S -D -H -s /sbin/nologin -h "$DATA_DIR" -G "$SERVICE_GROUP" "$SERVICE_USER"
        else
            useradd --system --gid "$SERVICE_GROUP" --no-create-home --shell /usr/sbin/nologin \
                --home-dir "$DATA_DIR" "$SERVICE_USER"
        fi
        ok "Created system user: ${SERVICE_USER}"
    fi
}

# ── Directories and config ────────────────────────────────────────────────────

install_dirs_and_config() {
    bold "Setting up directories and config..."

    mkdir -p "$CONFIG_DIR" "$DATA_DIR" "$WEB_PARENT" "$WEB_DIR" "$SERVICE_BIN_DIR"

    if [ -f "${CONFIG_DIR}/config.toml" ]; then
        ok "Config already exists at ${CONFIG_DIR}/config.toml — skipping (not overwritten)"
    else
        cat > "${CONFIG_DIR}/config.toml" << EOF
# ferrite configuration
# Edit to your needs, then restart the ferrite service.

web_dir = "${WEB_DIR}"
custom_records = []

[dns]
bind_addr = "[::]:53"
cache_size = 10000
min_ttl = 360
max_ttl = 3600
log_ignore = [
    "fe.te",
    "*.arpa",
    "*.local",
    "*.localdomain",
]

[[upstream]]
type = "https"
url = "https://cloudflare-dns.com/dns-query"
bootstrap_ip = "1.1.1.1"

[storage]
path               = "${DATA_DIR}/ferrite.db"
log_retention_days = 30

[api]
bind_addr = "0.0.0.0:80"

[panel]
enabled = true
domain = "fe.te"

[blocklist]
whitelist      = []
wildcard_block = []

[[blocklist.lists]]
name    = "StevenBlack"
url     = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
enabled = true
EOF
        ok "Config written to ${CONFIG_DIR}/config.toml"
    fi

    chown -R "$(service_owner)" "$CONFIG_DIR" "$DATA_DIR" "$WEB_PARENT" 2>/dev/null || true
    chmod 750 "$CONFIG_DIR" "$DATA_DIR" "$WEB_PARENT" "$WEB_DIR"
    chmod 640 "${CONFIG_DIR}/config.toml"

    if [ "$SERVICE_BIN_DIR" != "$BIN_DIR" ]; then
        chown -R "$(service_owner)" "$SERVICE_BIN_DIR" 2>/dev/null || true
        chmod 755 "$SERVICE_BIN_DIR"
    fi
}

# ── systemd-resolved conflict (Debian/Ubuntu) ────────────────────────────────

fix_resolved() {
    [ "$OS" = "linux" ] || return 0
    [ "$INIT_SYSTEM" = "systemd" ] || return 0
    systemctl is-active --quiet systemd-resolved 2>/dev/null || return 0

    if ss -ulnp 2>/dev/null | grep -q ':53 ' \
        || ss -tlnp 2>/dev/null | grep -q ':53 '; then
        info "Disabling systemd-resolved stub listener (it occupies port 53)..."
        mkdir -p /etc/systemd/resolved.conf.d
        cat > /etc/systemd/resolved.conf.d/ferrite.conf << 'EOF'
[Resolve]
DNSStubListener=no
EOF
        systemctl restart systemd-resolved
        ok "systemd-resolved stub listener disabled"
    fi
}

# ── Systemd service ───────────────────────────────────────────────────────────

install_systemd() {
    [ "$OS" = "linux" ] || return 0
    [ "$INIT_SYSTEM" = "systemd" ] || return 0

    bold "Installing systemd service..."

    cat > "$SYSTEMD_SERVICE" << EOF
[Unit]
Description=Ferrite DNS ad-blocker
Documentation=${GITHUB_BASE_URL}/${GITHUB_OWNER}/${GITHUB_REPO_SERVER}
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}

ExecStart=${SERVICE_BIN}
# Reload = restart in place: a fresh process from the binary on disk, keeping
# the DNS and panel sockets, so not a query is dropped.
ExecReload=/bin/kill -HUP \$MAINPID
Restart=always
RestartSec=5
TimeoutStopSec=30

# CAP_NET_BIND_SERVICE: bind port 53 without running as root.
# CAP_NET_ADMIN: kernel-WireGuard tunnels (netlink wg netdev + policy routing);
# without it wireguard egresses fall back to the userspace backend.
# CAP_NET_RAW: IPv6 router advertisements announcing ferrite as the DNS server
# (off unless switched on in the panel).
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_NET_RAW

# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=${DATA_DIR} ${CONFIG_DIR} ${WEB_PARENT} ${SERVICE_BIN_DIR}
RuntimeDirectory=ferrite

StandardOutput=journal
StandardError=journal
SyslogIdentifier=ferrite

[Install]
WantedBy=multi-user.target
EOF

    systemctl daemon-reload
    systemctl enable ferrite
    ok "systemd service installed and enabled"
}

# ── WireGuard kernel module (kernel-mode tunnels) ─────────────────────────────
# Best-effort: with the module loaded (and CAP_NET_ADMIN from the service unit)
# WireGuard egresses use the kernel backend — multicore crypto, line-rate
# throughput. Without it ferrite still works: tunnels fall back to the
# userspace backend automatically.

enable_wireguard_module() {
    [ "$OS" = "linux" ] || return 0
    if modprobe wireguard 2>/dev/null; then
        # Persist across reboots. Netdev creation would normally autoload the
        # module anyway; loading it explicitly gives a clear signal at install
        # time and survives hardened setups that disable autoload.
        if [ -d /etc/modules-load.d ]; then
            printf 'wireguard\n' > /etc/modules-load.d/ferrite.conf
        fi
        ok "wireguard kernel module loaded (kernel-mode tunnels available)"
    else
        info "wireguard kernel module unavailable — tunnels will use the userspace backend"
    fi
}

# ── OpenRC service (Alpine) ───────────────────────────────────────────────────

install_openrc() {
    [ "$OS" = "linux" ] || return 0
    [ "$INIT_SYSTEM" = "openrc" ] || return 0

    bold "Installing OpenRC service..."

    cat > "$OPENRC_SERVICE" << EOF
#!/sbin/openrc-run

name="ferrite"
description="Ferrite DNS ad-blocker"
supervisor=supervise-daemon
command="${SERVICE_BIN}"
command_user="${SERVICE_USER}:${SERVICE_GROUP}"
capabilities="^cap_net_bind_service,^cap_net_admin,^cap_net_raw"
respawn_delay=5
respawn_max=3
respawn_period=60
pidfile="/run/\${RC_SVCNAME}.pid"
output_log="/var/log/ferrite.log"
error_log="/var/log/ferrite.log"

depend() {
    need net
    after firewall
}

# Reload = restart in place: a fresh process from the binary on disk, keeping
# the DNS and panel sockets, so not a query is dropped.
extra_started_commands="reload"
reload() {
    ebegin "Restarting \${RC_SVCNAME} in place"
    supervise-daemon "\${RC_SVCNAME}" --signal HUP
    eend \$?
}
EOF
    chmod +x "$OPENRC_SERVICE"

    rc-update add ferrite default
    ok "OpenRC service installed and enabled (default runlevel, supervise-daemon)"
}

# ── Firewall: open port 53 ────────────────────────────────────────────────────

open_port_53() {
    [ "$OS" = "linux" ] || return 0

    bold "Opening port 53 in firewall..."

    if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "active"; then
        ufw allow 53/udp comment "ferrite DNS" >/dev/null
        ufw allow 53/tcp comment "ferrite DNS" >/dev/null
        ok "UFW: port 53/udp and 53/tcp allowed"

    elif command -v firewall-cmd >/dev/null 2>&1 \
        && systemctl is-active --quiet firewalld 2>/dev/null; then
        firewall-cmd --permanent --add-port=53/udp >/dev/null
        firewall-cmd --permanent --add-port=53/tcp >/dev/null
        firewall-cmd --reload >/dev/null
        ok "firewalld: port 53/udp and 53/tcp allowed"

    elif command -v iptables >/dev/null 2>&1; then
        iptables -C INPUT -p udp --dport 53 -j ACCEPT 2>/dev/null \
            || iptables -I INPUT -p udp --dport 53 -j ACCEPT
        iptables -C INPUT -p tcp --dport 53 -j ACCEPT 2>/dev/null \
            || iptables -I INPUT -p tcp --dport 53 -j ACCEPT
        warn "iptables: port 53 opened (not persistent). To persist:"
        if $IS_ALPINE; then
            warn "  apk add iptables && rc-update add iptables boot && rc-service iptables save"
        else
            warn "  Debian/Ubuntu: apt install iptables-persistent && netfilter-persistent save"
            warn "  RHEL/Rocky:    service iptables save"
        fi

    else
        warn "No active firewall detected. Make sure port 53 is accessible on your network interface."
    fi
}

# ── Start / restart service ───────────────────────────────────────────────────

start_service() {
    if [ "$OS" = "linux" ]; then
        bold "Starting ferrite service..."

        case "$INIT_SYSTEM" in
            systemd)
                if systemctl is-active --quiet ferrite 2>/dev/null; then
                    systemctl restart ferrite
                else
                    systemctl start ferrite
                fi
                sleep 1
                if systemctl is-active --quiet ferrite; then
                    ok "Service is running"
                else
                    warn "Service may have failed to start. Check: journalctl -u ferrite -n 30"
                fi
                ;;
            openrc)
                rc-service ferrite start
                sleep 1
                if rc-service ferrite status 2>/dev/null | grep -q "started"; then
                    ok "Service is running"
                else
                    warn "Service may have failed to start. Check: cat /var/log/ferrite.log"
                fi
                ;;
            *)
                warn "Unknown init system. Start manually: ${SERVICE_BIN}"
                ;;
        esac
    fi
}

# ── Main ──────────────────────────────────────────────────────────────────────

main() {
    echo
    bold "  Ferrite DNS ad-blocker — installer"
    printf "  ════════════════════════════════════\n"
    echo
    info "Platform: ${PLATFORM} (${INIT_SYSTEM})"
    echo

    install_server
    echo
    install_web
    echo
    create_user
    install_dirs_and_config
    echo
    fix_resolved
    case "$OS" in
        linux)
            enable_wireguard_module
            case "$INIT_SYSTEM" in
                systemd) install_systemd ;;
                openrc)  install_openrc  ;;
                *)       warn "Unknown init system — skipping service setup. Start manually: ${SERVICE_BIN}" ;;
            esac
            ;;
    esac
    echo
    open_port_53
    echo
    start_service

    # Determine LAN IP for display.
    if [ "$OS" = "linux" ]; then
        lan_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')
    else
        lan_ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || printf "localhost")
    fi
    lan_ip="${lan_ip:-localhost}"
    api_port=$(configured_api_port)
    api_port="${api_port:-80}"

    echo
    bold "  ✓ Installation complete!"
    echo
    printf "  Config:    %s/config.toml\n" "$CONFIG_DIR"
    printf "  Database:  %s/ferrite.db\n"  "$DATA_DIR"
    printf "  Web UI:    http://%s:%s\n" "$lan_ip" "$api_port"
    echo
    printf "  Next steps:\n"
    printf "    1. Point your router's DNS to %s\n" "$lan_ip"
    printf "    2. Run 'ferrite setup' to auto-detect your local network zones\n"
    printf "    3. Run 'ferrite passwd' to set a web UI password\n"
    echo
    case "$INIT_SYSTEM" in
        systemd)
            printf "  Service commands:\n"
            printf "    systemctl status ferrite\n"
            printf "    journalctl -u ferrite -f\n"
            printf "    systemctl restart ferrite\n"
            ;;
        openrc)
            printf "  Service commands:\n"
            printf "    rc-service ferrite status\n"
            printf "    tail -f /var/log/ferrite.log\n"
            printf "    rc-service ferrite restart\n"
            ;;
    esac
    echo
}

main "$@"