diff --git a/.gitignore b/.gitignore index 8f7237f..3894f8f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ CLAUDE.md # Editor *.swp *.swo +PROGRESS.md diff --git a/lynx/agent/setup-agent.sh b/lynx/agent/setup-agent.sh index 580792b..d28e913 100644 --- a/lynx/agent/setup-agent.sh +++ b/lynx/agent/setup-agent.sh @@ -58,6 +58,7 @@ PG_CONTAINER="lynx-agent-postgres" PG_IMAGE="docker.io/library/postgres@sha256:bfae840554bdbd4e9f8d097d8e23ffda8aac82866e04ea0d6bc09647234dd359" PG_DB="lynx_agent" PG_SUBNET="172.20.100.0/24" +PG_STATIC_IP="172.20.100.2" # Fixed IP — agent binary (root) connects directly, no host port mapping # Agent UUID v7 — generated on first install, persists across updates AGENT_ID="" @@ -174,6 +175,8 @@ read -rsp " Preshared key (PSK): " PSK echo "" read -rp " Agent WireGuard IP assigned by dashboard (e.g. 10.100.0.3): " AGENT_WG_IP_INPUT echo "" +read -rsp " Sync token (shown once when registering this VPS in the dashboard): " SYNC_TOKEN +echo "" # Dashboard Ed25519 signing public key — required for the agent to verify # every dashboard-signed command (heartbeat ACK, container ops, nftables push, @@ -196,8 +199,8 @@ fi unset DEFAULT_DASHBOARD_SIGN_PUBKEY echo "" -if [[ -z "$DASHBOARD_ENDPOINT" || -z "$DASHBOARD_PUBKEY" || -z "$PSK" || -z "$AGENT_WG_IP_INPUT" || -z "$DASHBOARD_SIGN_PUBKEY" ]]; then - log_error "All five values are required (endpoint, WG pubkey, PSK, agent WG IP, dashboard signing pubkey)." +if [[ -z "$DASHBOARD_ENDPOINT" || -z "$DASHBOARD_PUBKEY" || -z "$PSK" || -z "$AGENT_WG_IP_INPUT" || -z "$DASHBOARD_SIGN_PUBKEY" || -z "$SYNC_TOKEN" ]]; then + log_error "All six values are required (endpoint, WG pubkey, PSK, agent WG IP, dashboard signing pubkey, sync token)." exit 1 fi @@ -395,6 +398,10 @@ _require_cmd() { log_ok "$1 found" } +# Podman: use Ubuntu 24.04 noble-updates package (4.9.3+). The kubic/libcontainers +# upstream repo does not publish packages for Ubuntu 24.04 yet. When an official +# upstream repo with a verifiable GPG fingerprint becomes available for noble, +# replace this with repo-pinned install + fingerprint check. _apt_ensure podman podman # openssl replaced by `lynx-agent` subcommands for random/keypair ops. _apt_ensure nft nftables @@ -429,7 +436,6 @@ done # require iptables-nft. Lynx upgrades from upstream so the iptables package can # be dropped entirely (it remains on the incompatible-software list). NETAVARK_REQUIRED="1.10.0" -NETAVARK_UPSTREAM_VER="1.15.2" _netavark_bin="" for _candidate in /usr/lib/podman/netavark /usr/libexec/podman/netavark; do [[ -x "$_candidate" ]] && _netavark_bin="$_candidate" && break @@ -448,6 +454,17 @@ _version_lt() { if _version_lt "$_netavark_ver" "$NETAVARK_REQUIRED"; then log_warn "netavark $_netavark_ver < $NETAVARK_REQUIRED — upgrading from upstream" + + log_info "Fetching latest netavark release from GitHub..." + NETAVARK_UPSTREAM_VER=$(curl -fsSL --max-time 15 \ + "https://api.github.com/repos/containers/netavark/releases/latest" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'].lstrip('v'))" 2>/dev/null) + if [[ -z "$NETAVARK_UPSTREAM_VER" ]]; then + log_error "Could not fetch latest netavark version from GitHub API" + exit 1 + fi + log_info "Latest netavark: v${NETAVARK_UPSTREAM_VER}" + _uname_m="$(uname -m)" case "$_uname_m" in x86_64|amd64) _na_asset="netavark.gz" ;; @@ -461,6 +478,27 @@ if _version_lt "$_netavark_ver" "$NETAVARK_REQUIRED"; then rm -f "$NETAVARK_TMP" exit 1 fi + + # Verify sha256 against the checksum published in the release. + # netavark does not publish GPG signatures — sha256sum protects against + # corruption and MITM in transit (over HTTPS to github.com). + log_info "Verifying netavark sha256..." + _sha256_url="https://github.com/containers/netavark/releases/download/v${NETAVARK_UPSTREAM_VER}/sha256sum" + _expected_sha=$(curl -fsSL --max-time 15 "$_sha256_url" 2>/dev/null \ + | grep "[[:space:]]${_na_asset}$" | awk '{print $1}') + if [[ -z "$_expected_sha" ]]; then + log_error "Could not fetch sha256 for ${_na_asset} from ${_sha256_url}" + rm -f "$NETAVARK_TMP" + exit 1 + fi + _actual_sha=$(sha256sum "$NETAVARK_TMP" | awk '{print $1}') + if [[ "$_actual_sha" != "$_expected_sha" ]]; then + log_error "netavark sha256 mismatch — expected ${_expected_sha}, got ${_actual_sha}" + rm -f "$NETAVARK_TMP" + exit 1 + fi + log_ok "netavark sha256 verified" + gunzip -f "$NETAVARK_TMP" install -m 755 "${NETAVARK_TMP%.gz}" "$_netavark_bin" rm -f "${NETAVARK_TMP%.gz}" @@ -737,7 +775,7 @@ log_section "Starting PostgreSQL for agent" podman run -d \ --name "$PG_CONTAINER" \ --network "$PG_NETWORK" \ - --publish 127.0.0.1:5434:5432 \ + --ip "$PG_STATIC_IP" \ --secret lynx-agent-pg-root,target=lynx-agent-pg-root \ --secret lynx-agent-pg-pass,target=lynx-agent-pg-pass \ -e POSTGRES_USER=postgres \ @@ -762,10 +800,11 @@ for i in $(seq 1 40); do sleep 2 done -# Write DATABASE_URL using the host-mapped port — stable across container restarts -# regardless of which IP the container is assigned inside the Podman network. +# Write DATABASE_URL using the container's static IP on the internal Podman network. +# The agent binary runs as root and can reach the container network directly — no host +# port mapping needed (which would create iptables DNAT rules that survive reinstalls). ( - DB_URL="postgresql://lynx_agent_app:${PG_PASS}@127.0.0.1:5434/${PG_DB}" + DB_URL="postgresql://lynx_agent_app:${PG_PASS}@${PG_STATIC_IP}:5432/${PG_DB}" printf '%s' "$DB_URL" > /etc/lynx/credentials/database-url chmod 600 /etc/lynx/credentials/database-url DB_URL="$("$BINARY_PATH" gen-rand 32)" @@ -796,7 +835,9 @@ AGENT_ID=${AGENT_ID} DATABASE_URL_FILE=/run/credentials/lynx-agent.service/database-url INTERNAL_TOKEN_FILE=/run/credentials/lynx-agent.service/internal-token DASHBOARD_VERIFY_KEY_FILE=/run/credentials/lynx-agent.service/lynx-dashboard-pubkey +SYNC_TOKEN_FILE=/run/credentials/lynx-agent.service/sync-token LISTEN_ADDR=127.0.0.1:${AGENT_PORT} +DASHBOARD_URL=http://${DASHBOARD_WG_IP}:8080 RUST_LOG=info ${DASHBOARD_PORT_CONF} EOF @@ -821,6 +862,13 @@ printf '%s' "$DASHBOARD_SIGN_PUBKEY" > /etc/lynx/credentials/lynx-dashboard-pubk chmod 600 /etc/lynx/credentials/lynx-dashboard-pubkey unset DASHBOARD_SIGN_PUBKEY +# Persist the sync token — used to authenticate the agent→dashboard WebSocket +# connection and audit log sync. Shown once when registering the VPS. +printf '%s' "$SYNC_TOKEN" > /etc/lynx/credentials/sync-token +chmod 600 /etc/lynx/credentials/sync-token +SYNC_TOKEN="$("$BINARY_PATH" gen-rand 32)" +unset SYNC_TOKEN + # --- Create systemd service ------------------------------------------------- log_section "Installing systemd service" @@ -864,6 +912,7 @@ TimeoutStopSec=30s LoadCredential=database-url:/etc/lynx/credentials/database-url LoadCredential=internal-token:/etc/lynx/credentials/internal-token LoadCredential=lynx-dashboard-pubkey:/etc/lynx/credentials/lynx-dashboard-pubkey +LoadCredential=sync-token:/etc/lynx/credentials/sync-token # Minimal hardening — agent is a privileged system daemon (package management, # nftables, system user creation, binary self-update all require root). @@ -885,6 +934,9 @@ log_section "Configuring WireGuard tunnel (agent ↔ dashboard)" # Generate agent keypair AGENT_PRIV=$(wg genkey) AGENT_PUB=$(printf '%s' "$AGENT_PRIV" | wg pubkey) +log_info "Agent WireGuard public key: ${AGENT_PUB}" +log_info " Register this VPS in the dashboard with the above public key" +log_info " The dashboard will provide the PSK, WG IP, and sync token" # --- NAT detection --- # Extract the dashboard host (strip port if present) @@ -894,8 +946,12 @@ DASHBOARD_HOST="${DASHBOARD_ENDPOINT%%:*}" LOCAL_IFACE_IP=$(ip route get "$DASHBOARD_HOST" 2>/dev/null | grep -oP 'src \K\S+' | head -1) # Public IP as seen from the internet -PUBLIC_IP=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null || \ - curl -sf --max-time 5 https://api.ipify.org 2>/dev/null || true) +PUBLIC_IP=$(curl -4 -sf --max-time 5 https://ifconfig.me 2>/dev/null || \ + curl -4 -sf --max-time 5 https://api.ipify.org 2>/dev/null || true) +if [[ -z "$PUBLIC_IP" ]]; then + PUBLIC_IP=$(curl -6 -sf --max-time 5 https://ifconfig.me 2>/dev/null || \ + curl -6 -sf --max-time 5 https://api6.ipify.org 2>/dev/null || true) +fi KEEPALIVE_LINE="" diff --git a/lynx/agent/src/nftables/mod.rs b/lynx/agent/src/nftables/mod.rs index 2d76cd2..38b8b05 100644 --- a/lynx/agent/src/nftables/mod.rs +++ b/lynx/agent/src/nftables/mod.rs @@ -123,6 +123,16 @@ fn render_ruleset(r: &Ruleset) -> String { None => String::new(), }; + // Management plane rules — dashboard VPS only. + // Agents (10.100.0.x) need to reach the backend on port 8080; agent-to-agent + // traffic within the management subnet must be blocked; and the dashboard itself + // (10.100.0.1) is allowed unconditionally on its own WireGuard interface. + let management_plane_rules = if r.dashboard_port.is_some() { + "\n # Allow agents -> dashboard backend (management plane)\n ip saddr 10.100.0.0/16 ip daddr 10.100.0.1 tcp dport 8080 ct state new accept\n\n # Block agent-to-agent traffic within management subnet\n ip saddr 10.100.0.0/16 ip daddr 10.100.0.0/16 drop\n\n # Dashboard WireGuard interface can reach itself\n ip saddr 10.100.0.1 accept\n".to_string() + } else { + String::new() + }; + // Container DNS (aardvark-dns on Netavark bridges) — dashboard VPS only. // Rootless org containers on remote agents use user-namespace networking // that doesn't hit the host INPUT chain for DNS. @@ -172,7 +182,7 @@ table inet {TABLE} {{ udp dport {wg} accept # Dashboard backend (management plane — WireGuard only) - ip saddr 10.100.0.1 accept +{management_plane} {dashboard_port} {dashboard_dns} # Run global and local rule chains @@ -201,6 +211,7 @@ table inet {TABLE} {{ "#, TABLE = TABLE, wg = r.wireguard_port, + management_plane = management_plane_rules, dashboard_port = dashboard_port_rule, dashboard_dns = dashboard_dns_rules, container_forward = container_forward_rules, @@ -334,12 +345,24 @@ mod tests { #[test] fn render_contains_dashboard_management_ip() { - let r = minimal_ruleset(); + // Management plane rules only render when dashboard_port is set (dashboard VPS). + let mut r = minimal_ruleset(); + r.dashboard_port = Some(19443); let out = render_ruleset(&r); - // The dashboard backend is always at 10.100.0.1 assert!( out.contains("10.100.0.1"), - "dashboard management IP missing" + "dashboard management IP missing when dashboard_port set" + ); + assert!( + out.contains("10.100.0.0/16"), + "agent subnet missing from management plane rules" + ); + // Without dashboard_port, management plane rules must not appear. + let r_agent = minimal_ruleset(); + let out_agent = render_ruleset(&r_agent); + assert!( + !out_agent.contains("10.100.0.0/16"), + "management plane rules must not render on remote agent" ); } diff --git a/lynx/dashboard/server/src/admin/handlers/rotation.rs b/lynx/dashboard/server/src/admin/handlers/rotation.rs index 395be54..38827fa 100644 --- a/lynx/dashboard/server/src/admin/handlers/rotation.rs +++ b/lynx/dashboard/server/src/admin/handlers/rotation.rs @@ -316,6 +316,13 @@ pub async fn rotate_pg_app_password(state: &AppState) -> Result<(), AppError> { tracing::info!("PostgreSQL app password rotated and Podman secrets updated"); } + if let Err(e) = std::fs::write( + "/etc/lynx/secrets/lynx-dashboard-database-url", + new_db_url.as_bytes(), + ) { + tracing::warn!("host file write for database-url failed: {e}"); + } + Ok(()) } @@ -366,6 +373,13 @@ pub async fn rotate_redis_password(state: &AppState) -> Result<(), AppError> { tracing::info!("Redis password rotated and Podman secrets updated"); } + if let Err(e) = std::fs::write( + "/etc/lynx/secrets/lynx-dashboard-redis-url", + new_redis_url.as_bytes(), + ) { + tracing::warn!("host file write for redis-url failed: {e}"); + } + Ok(()) } diff --git a/lynx/dashboard/server/src/scheduler.rs b/lynx/dashboard/server/src/scheduler.rs index 5e4938c..d98f6b6 100644 --- a/lynx/dashboard/server/src/scheduler.rs +++ b/lynx/dashboard/server/src/scheduler.rs @@ -286,7 +286,7 @@ pub(crate) async fn needs_scheduled_rotation(db: &sqlx::PgPool) -> bool { ) .fetch_one(db) .await - .unwrap_or(None); + .unwrap_or(Some(chrono::Utc::now())); match last { None => true, diff --git a/lynx/dashboard/setup-dashboard.sh b/lynx/dashboard/setup-dashboard.sh index 7f10e0c..d146506 100644 --- a/lynx/dashboard/setup-dashboard.sh +++ b/lynx/dashboard/setup-dashboard.sh @@ -54,6 +54,15 @@ AGENT_WG_PORT=51820 AGENT_WG_IP="10.100.0.2" DASHBOARD_WG_IP="10.100.0.1" +# Podman network subnets — fixed to prevent stale DNAT when containers restart. +# Container static IPs are hardcoded in the compose YAML below (.1 = gateway, .2+ = containers): +# lynx-dashboard-db 10.89.0.0/24 postgres=10.89.0.2 backend=10.89.0.3 +# lynx-dashboard-cache 10.89.1.0/24 valkey=10.89.1.2 backend=10.89.1.3 +# lynx-dashboard-app 10.89.2.0/24 backend=10.89.2.2 frontend=10.89.2.3 nginx=10.89.2.4 +DASHBOARD_DB_SUBNET="10.89.0.0/24" +DASHBOARD_CACHE_SUBNET="10.89.1.0/24" +DASHBOARD_APP_SUBNET="10.89.2.0/24" + # --- Root check ------------------------------------------------------------- if [[ $EUID -ne 0 ]]; then @@ -103,9 +112,12 @@ _cleanup_existing() { | grep -E 'postgres_data|frontend_next_cache' \ | xargs -r podman volume rm 2>/dev/null || true - # Remove networks + # Remove networks. Also purge stale aardvark-dns config files so the + # next network create starts with clean DNS state (stale files cause the + # DNS gateway to reference the old subnet, breaking hostname resolution). for net in lynx-dashboard-db lynx-dashboard-cache lynx-dashboard-app; do podman network rm "$net" 2>/dev/null || true + rm -f "/run/containers/networks/aardvark-dns/$net" 2>/dev/null || true done # Remove known secrets then sweep any remaining lynx-* secrets @@ -136,9 +148,10 @@ _cleanup_existing() { rm -f /etc/systemd/system/lynx-dashboard-rotate-certs.{service,timer} systemctl daemon-reload - # Flush nftables table so container DNS queries are not blocked during reinstall + # Flush nftables tables managed by the dashboard install nft delete table inet lynx-dashboard 2>/dev/null || true rm -f /etc/nftables-lynx-dashboard.conf + nft delete table inet lynx-agent 2>/dev/null || true rm -rf "$LYNX_DIR" log_ok "Cleanup complete" @@ -152,6 +165,13 @@ for _ctr in lynx-dashboard-postgres lynx-dashboard-valkey lynx-dashboard-backend podman stop --time 5 "$_ctr" 2>/dev/null || true podman rm -f "$_ctr" 2>/dev/null || true done +# Purge stale aardvark-dns config files for dashboard networks. When containers +# are force-removed, aardvark-dns sometimes retains phantom entries that cause +# the next network create to use a stale gateway IP, breaking DNS resolution. +for _net in lynx-dashboard-db lynx-dashboard-cache lynx-dashboard-app; do + rm -f "/run/containers/networks/aardvark-dns/$_net" 2>/dev/null || true +done +unset _net # --- RAM check -------------------------------------------------------------- @@ -551,12 +571,17 @@ log_ok "Directories created" log_section "Creating Podman networks" -for net in lynx-dashboard-db lynx-dashboard-cache lynx-dashboard-app; do +for spec in \ + "lynx-dashboard-db:${DASHBOARD_DB_SUBNET}" \ + "lynx-dashboard-cache:${DASHBOARD_CACHE_SUBNET}" \ + "lynx-dashboard-app:${DASHBOARD_APP_SUBNET}"; do + net="${spec%%:*}" + subnet="${spec##*:}" if podman network exists "$net" 2>/dev/null; then log_warn "Network $net already exists — skipping" else - podman network create "$net" - log_ok "Network created: $net" + podman network create "$net" --subnet "$subnet" + log_ok "Network created: $net ($subnet)" fi done @@ -859,7 +884,8 @@ services: start_period: 10s restart: unless-stopped networks: - - lynx-dashboard-app + lynx-dashboard-app: + ipv4_address: 10.89.2.4 frontend: container_name: lynx-dashboard-frontend @@ -885,7 +911,8 @@ services: start_period: 30s restart: unless-stopped networks: - - lynx-dashboard-app + lynx-dashboard-app: + ipv4_address: 10.89.2.3 backend: container_name: lynx-dashboard-backend @@ -940,9 +967,12 @@ services: start_period: 15s restart: unless-stopped networks: - - lynx-dashboard-db - - lynx-dashboard-cache - - lynx-dashboard-app + lynx-dashboard-db: + ipv4_address: 10.89.0.3 + lynx-dashboard-cache: + ipv4_address: 10.89.1.3 + lynx-dashboard-app: + ipv4_address: 10.89.2.2 postgres: container_name: lynx-dashboard-postgres @@ -962,7 +992,8 @@ services: retries: 10 restart: unless-stopped networks: - - lynx-dashboard-db + lynx-dashboard-db: + ipv4_address: 10.89.0.2 valkey: container_name: lynx-dashboard-valkey @@ -982,7 +1013,8 @@ services: retries: 10 restart: unless-stopped networks: - - lynx-dashboard-cache + lynx-dashboard-cache: + ipv4_address: 10.89.1.2 volumes: postgres_data: @@ -1111,6 +1143,31 @@ wg-quick up wg-lynx-dash systemctl enable "wg-quick@wg-lynx-dash" log_ok "WireGuard interface up: wg-lynx-dash (10.100.0.1/16)" +# Ensure DNS from dashboard containers is accepted. +# The agent binary (if already running from a prior install) regenerates +# table inet lynx-agent on startup and omits the DNS accept rules from the +# input chain — blocking aardvark-dns from dashboard containers. +# Insert the rules BEFORE the trailing drop so they survive any dynamic +# chain updates the agent makes during normal operation. +_nft_ensure_container_dns() { + # No-op if table/chain doesn't exist yet (first install, bootstrap applies it below) + nft list chain inet lynx-agent lynx-base &>/dev/null || return 0 + # If rules already present, skip + nft list chain inet lynx-agent lynx-base 2>/dev/null | grep -q 'iifname.*podman.*dport 53.*accept' && return 0 + # Insert just before the terminal drop rule — find its handle + local drop_handle + drop_handle=$(nft -a list chain inet lynx-agent lynx-base 2>/dev/null | grep '^\s*drop' | grep -o 'handle [0-9]*' | head -1 | awk '{print $2}') + if [[ -n "$drop_handle" ]]; then + nft insert rule inet lynx-agent lynx-base handle "$drop_handle" iifname "podman*" udp dport 53 accept 2>/dev/null || true + nft insert rule inet lynx-agent lynx-base handle "$drop_handle" iifname "podman*" tcp dport 53 accept 2>/dev/null || true + else + nft add rule inet lynx-agent lynx-base iifname "podman*" udp dport 53 accept 2>/dev/null || true + nft add rule inet lynx-agent lynx-base iifname "podman*" tcp dport 53 accept 2>/dev/null || true + fi + log_ok "DNS rules injected into lynx-agent.lynx-base for container aardvark-dns" +} +_nft_ensure_container_dns + # 3. Backend log_info "Starting backend..." "$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d backend @@ -1217,7 +1274,7 @@ log_section "Configuring nginx TLS reverse proxy" # every request so it picks up the new container IP after an auto-update restart. NGINX_RESOLVER=$(podman network inspect lynx-dashboard-app \ --format '{{range .Subnets}}{{.Gateway}}{{end}}' 2>/dev/null \ - || echo "10.89.0.1") + || echo "10.89.2.1") cat > "$NGINX_DIR/default.conf" << NGINXEOF server { listen 19443 ssl; @@ -1342,7 +1399,13 @@ table inet lynx-agent { # WireGuard (agent tunnels) udp dport 51820 accept - # Dashboard backend accessible from WireGuard management plane only + # Allow agents -> dashboard backend (management plane) + ip saddr 10.100.0.0/16 ip daddr 10.100.0.1 tcp dport 8080 ct state new accept + + # Block agent-to-agent traffic within management subnet + ip saddr 10.100.0.0/16 ip daddr 10.100.0.0/16 drop + + # Dashboard WireGuard interface can reach itself ip saddr 10.100.0.1 accept jump lynx-global @@ -1381,12 +1444,34 @@ EOF nft -f /etc/nftables-lynx-agent.conf log_ok "nftables rules applied (ports: 22 rate-limited, 19443, 51820 UDP)" +# Dashboard-specific nftables table — separate from table inet lynx-agent so the +# agent binary never overwrites these rules. The agent manages only lynx-agent; +# this table persists across agent nftables reloads. +# Without this, aardvark-dns (on podman* bridges) is unreachable from containers +# after the agent binary starts and re-renders its ruleset without DNS accept rules. +cat > /etc/nftables-lynx-dashboard.conf << 'NFT_DASH' +destroy table inet lynx-dashboard +table inet lynx-dashboard { + chain allow-container-dns { + type filter hook input priority filter - 1; policy accept; + iifname "podman*" udp dport 53 accept + iifname "podman*" tcp dport 53 accept + } +} +NFT_DASH + +nft -f /etc/nftables-lynx-dashboard.conf +log_ok "Dashboard nftables (container DNS) applied" + # Persist across reboots — migrate away from old lynx-dashboard include if [[ -f /etc/nftables.conf ]]; then sed -i '/nftables-lynx-dashboard/d' /etc/nftables.conf if ! grep -q "nftables-lynx-agent" /etc/nftables.conf; then echo 'include "/etc/nftables-lynx-agent.conf"' >> /etc/nftables.conf fi + if ! grep -q "nftables-lynx-dashboard" /etc/nftables.conf; then + echo 'include "/etc/nftables-lynx-dashboard.conf"' >> /etc/nftables.conf + fi fi systemctl enable nftables 2>/dev/null || true