{ config, lib, pkgs, xlib, ... }: let panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/3x-ui"; # Domain whose Let's Encrypt cert (at /var/lib/acme//) gets mounted # read-only into the 3x-ui container so the panel can terminate TLS itself. # Null when 3x-ui serves plain HTTP and TLS is terminated by an upstream # nginx. certDomain = config.host."3x-ui".certDomain; certMounts = if certDomain == null then [ ] else # LE cert mounted read-only so 3x-ui can terminate TLS itself. # The 3x-ui settings table must point webCertFile / webKeyFile at # /root/cert/fullchain.pem and /root/cert/key.pem. map (f: "/var/lib/acme/${certDomain}/${f}:/root/cert/${f}:ro") [ "fullchain.pem" "key.pem" ]; basePorts = [ # Local-only upstreams for the 3x-ui panel and subscription endpoint. # The direct Xray inbound remains publicly reachable on 8443. "127.0.0.1:2049:2049/tcp" "127.0.0.1:2096:2096/tcp" "0.0.0.0:8443:8443/tcp" ]; # VDS-only: nginx stream forwards host:443 → 127.0.0.1:15380 → # container:443, so Xray sees its REALITY inbound on port 443. realityPorts = lib.optional config.host."3x-ui".reality443Forwarding "127.0.0.1:15380:443/tcp"; in { options.host."3x-ui" = { # Domain whose LE cert should be mounted into the 3x-ui container at # /root/cert/fullchain.pem and key.pem. Set null if 3x-ui serves plain # HTTP and TLS is terminated by an upstream nginx. certDomain = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "pubray1.zeroq.su"; description = '' Domain whose LE cert should be mounted into the 3x-ui container at /root/cert/fullchain.pem and key.pem. ''; }; # Publish host:15380 → container:443. Only nodes that host an Xray # REALITY inbound on container:443 need this (so nginx stream can # forward TLS to Xray via 127.0.0.1:15380 while Xray itself sees # incoming connections on its configured port 443). Set false on nodes # that only run the 3x-ui panel. reality443Forwarding = lib.mkOption { type = lib.types.bool; default = false; description = '' When true, publish host:15380 → container:443 so Xray inside the container can serve REALITY on its real configured port 443 (nginx stream forwards 443 → 15380). ''; }; }; config = { virtualisation = { podman = { enable = true; autoPrune = { enable = true; flags = [ "--all" ]; }; dockerCompat = true; }; oci-containers = { backend = "podman"; containers."3xui_app" = { image = "ghcr.io/mhsanaei/3x-ui:latest"; environment = { "XRAY_VMESS_AEAD_FORCED" = "false"; "XUI_ENABLE_FAIL2BAN" = "true"; "TZ" = "Europe/Moscow"; }; volumes = [ "${panel}/cert/:/root/cert:rw" "${panel}/db/:/etc/x-ui:rw" ] ++ certMounts; log-driver = "journald"; # Adding a new inbound through the 3x-ui panel on a port outside # the 14380-15379 range requires extending basePorts and rebuilding. ports = basePorts ++ realityPorts; }; }; }; systemd = { services = { "podman-3xui_app" = { serviceConfig.Restart = lib.mkOverride 90 "always"; partOf = [ "podman-compose-3x-ui-root.target" ]; wantedBy = [ "podman-compose-3x-ui-root.target" ]; }; "podman-update-3xui_app" = { path = [ pkgs.podman ]; serviceConfig = { Type = "oneshot"; TimeoutSec = 300; }; script = '' podman pull ghcr.io/mhsanaei/3x-ui:latest systemctl restart podman-3xui_app.service ''; }; }; # Starts/stops together with all 3x-ui compose resources. targets."podman-compose-3x-ui-root" = { unitConfig.Description = "Root target generated by compose2nix."; wantedBy = [ "multi-user.target" ]; }; # timers."podman-update-3xui_app" = { # wantedBy = [ "timers.target" ]; # timerConfig = { # OnCalendar = "weekly"; # Persistent = true; # }; # }; tmpfiles.rules = [ (xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root") (xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root") (xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755" "root" "root" ) (xlib.helpers.mkTmpfile "d" panel "0755" "root" "root") (xlib.helpers.mkTmpfile "d" "${panel}/db" "0755" "root" "root") (xlib.helpers.mkTmpfile "d" "${panel}/cert" "0755" "root" "root") # Relabel panel dir for SELinux so containers can access it. (xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root") ]; }; # Enable container name DNS for all Podman networks. networking.firewall = { interfaces = let matchAll = if !config.networking.nftables.enable then "podman+" else "podman*"; in { "${matchAll}".allowedUDPPorts = [ 53 ]; }; }; }; }