main
  1{
  2  pkgs,
  3  config,
  4  ...
  5}:
  6{
  7  imports = [
  8    ../common/programs/direnv.nix
  9    ../common/programs/git.nix
 10    ../common/programs/nix-ld.nix
 11    ../common/programs/tmux.nix
 12    ../common/services/networkmanager.nix
 13    ../common/services/containers.nix
 14    ../common/services/docker.nix
 15    ../common/services/binfmt.nix
 16
 17    ../common/services/oomd.nix
 18    ../../modules/laptop-keyboard-inhibit
 19    # Build and cache infrastructure
 20    ../../modules/harmonia
 21    ../../modules/job-notify
 22    ../../modules/flux-generate
 23
 24  ];
 25
 26  # Disable built-in MediaTek MT7922 WiFi (using USB TP-Link AC600 instead)
 27  boot.blacklistedKernelModules = [ "mt7921e" ];
 28
 29  # Xbox controller support (Bluetooth)
 30  hardware.xpadneo.enable = true;
 31
 32  # Gaming setup
 33  programs.steam = {
 34    enable = true;
 35    remotePlay.openFirewall = true; # Steam Link support
 36    dedicatedServer.openFirewall = true; # Game server hosting
 37  };
 38
 39  programs.gamemode = {
 40    enable = true;
 41    settings = {
 42      general = {
 43        renice = 10; # Increase game process priority
 44      };
 45      gpu = {
 46        apply_gpu_optimisations = "accept-responsibility";
 47        gpu_device = 0;
 48        amd_performance_level = "high"; # AMD GPU performance mode
 49      };
 50    };
 51  };
 52
 53  # Greeter wallpaper (accessible to greeter user)
 54  environment.etc."greetd/wallpaper.jpg".source = ../../dots/wallpapers/greeter.jpg;
 55
 56  # QMK/ZMK keyboard flashing support (udev rules + plugdev group)
 57  hardware.keyboard.qmk.enable = true;
 58
 59  # Auto-disable internal keyboard when Eyelash Corne (BT) is connected
 60  services.laptop-keyboard-inhibit = {
 61    enable = false; # disabled: spams D-Bus with bluetooth reconnect attempts
 62    keyboards = [ "Eyelash Corne" ];
 63  };
 64
 65  # Age secrets
 66  age.secrets = {
 67    "opencode-password" = {
 68      file = ../../secrets/okinawa/opencode-password.age;
 69      mode = "400";
 70      owner = "vincent";
 71    };
 72    "groq-api-key" = {
 73      file = ../../secrets/okinawa/groq-api-key.age;
 74      mode = "400";
 75      owner = "vincent";
 76    };
 77    "openrouter-api-key" = {
 78      file = ../../secrets/okinawa/openrouter-api-key.age;
 79      mode = "400";
 80      owner = "vincent";
 81    };
 82    "gemini-api-key" = {
 83      file = ../../secrets/okinawa/gemini-api-key.age;
 84      mode = "400";
 85      owner = "vincent";
 86    };
 87    "xmpp-research-bot-password" = {
 88      file = ../../secrets/okinawa/xmpp-research-bot-password.age;
 89      mode = "400";
 90      owner = "vincent";
 91    };
 92    # Harmonia binary cache signing key
 93    "harmonia-okinawa-signing-key" = {
 94      file = ../../secrets/harmonia/okinawa-signing-key.age;
 95      mode = "440";
 96      owner = "root";
 97      group = "root";
 98    };
 99    # ntfy notification token (shared secret)
100    "ntfy-token" = {
101      file = ../../secrets/sakhalin/ntfy-token.age;
102      mode = "440";
103      owner = "root";
104      group = "users";
105    };
106  };
107
108  # Binary cache server (x86_64-linux)
109  services.harmonia-cache = {
110    enable = true;
111    signKeyPath = config.age.secrets."harmonia-okinawa-signing-key".path;
112    port = 5000;
113    workers = 4;
114    priority = 30;
115
116    # Nightly cache pre-population
117    builder = {
118      enable = true;
119      systems = [
120        "okinawa" # Self
121        "kyushu" # Work laptop
122        "sakhalin" # Server
123      ];
124      schedule = "02:00"; # 2 AM daily
125      notification = {
126        enable = true;
127        tokenFile = config.age.secrets."ntfy-token".path;
128      };
129    };
130  };
131
132  # Remote build system
133  services.job-notify = {
134    enable = true;
135    ntfyServer = "https://ntfy.sbr.pm";
136    ntfyTokenFile = config.age.secrets."ntfy-token".path;
137    defaultTopic = "builds";
138  };
139
140  # Website flux auto-generate and deploy (hourly)
141  services.flux-generate.enable = true;
142
143  # OpenCode web interface for remote AI coding
144  # Accessible via opencode.sbr.pm through rhea's Traefik reverse proxy
145  systemd.services.opencode-web =
146    let
147      opencode-config = pkgs.writeText "opencode.json" (
148        builtins.toJSON {
149          "$schema" = "https://opencode.ai/config.json";
150          provider = {
151            # Local llama-server (OpenAI-compatible API)
152            llama-server = {
153              npm = "@ai-sdk/openai-compatible";
154              name = "llama-server (local)";
155              options = {
156                baseURL = "http://127.0.0.1:8090/v1";
157                apiKey = "local-test";
158              };
159              models = {
160                "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M" = {
161                  name = "Qwen3 Coder 30B MoE";
162                };
163                "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M" = {
164                  name = "Qwen 2.5 Coder 7B";
165                };
166                "Qwen/Qwen3-8B-GGUF:Q4_K_M" = {
167                  name = "Qwen3 8B";
168                };
169                "bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF:Q4_K_M" = {
170                  name = "DeepSeek R1 Distill 7B";
171                };
172                "unsloth/Phi-4-mini-instruct-GGUF:Q4_K_M" = {
173                  name = "Phi-4 mini 3.8B";
174                };
175                "Qwen/Qwen3-Coder-Next-GGUF:Q3_K_M" = {
176                  name = "Qwen3 Coder Next 80B MoE";
177                };
178              };
179            };
180            # Vertex AI (Claude on Google Cloud)
181            google-vertex = {
182              options = {
183                project = "itpc-gcp-pnd-pe-eng-claude";
184                location = "global";
185              };
186            };
187          };
188          server = {
189            hostname = "0.0.0.0";
190            port = 5555;
191          };
192        }
193      );
194    in
195    {
196      description = "OpenCode web interface";
197      after = [
198        "network.target"
199        "llama-cpp.service"
200      ];
201      wantedBy = [ "multi-user.target" ];
202
203      serviceConfig = {
204        Type = "simple";
205        WorkingDirectory = "/home/vincent";
206        ExecStart = pkgs.writeShellScript "opencode-web-start" ''
207          # Load secrets into environment
208          export OPENCODE_SERVER_PASSWORD=$(cat ${config.age.secrets."opencode-password".path})
209          export GROQ_API_KEY=$(cat ${config.age.secrets."groq-api-key".path})
210          export OPENROUTER_API_KEY=$(cat ${config.age.secrets."openrouter-api-key".path})
211          export GOOGLE_GENERATIVE_AI_API_KEY=$(cat ${config.age.secrets."gemini-api-key".path})
212          exec ${pkgs.opencode}/bin/opencode web
213        '';
214        Restart = "on-failure";
215        RestartSec = 10;
216
217        User = "vincent";
218        Group = "users";
219      };
220
221      environment = {
222        HOME = "/home/vincent";
223        OPENCODE_SERVER_USERNAME = "opencode";
224        # Config file location
225        XDG_CONFIG_HOME = "/run/opencode/config";
226        GOOGLE_VERTEX_PROJECT = "itpc-gcp-pnd-pe-eng-claude";
227        GOOGLE_VERTEX_LOCATION = "global";
228      };
229
230      # Copy config file before start (--remove-destination needed because
231      # the nix store source is read-only, so cp can't overwrite in place)
232      preStart = ''
233        mkdir -p /run/opencode/config/opencode
234        cp --remove-destination ${opencode-config} /run/opencode/config/opencode/opencode.json
235      '';
236    };
237
238  # Ensure /run/opencode directory exists
239  systemd.tmpfiles.rules = [
240    "d /run/opencode 0755 vincent users -"
241    "d /run/opencode/config 0755 vincent users -"
242  ];
243
244  # Firewall: OpenCode web + monitoring + Harmonia
245  # (llama-server port 8090 removed: service disabled, config kept for re-enablement)
246  networking.firewall.allowedTCPPorts = [
247    5000 # Harmonia binary cache
248    5555 # OpenCode web
249    8880 # readwise-reader triage report
250    9000 # Prometheus node exporter
251  ];
252
253  # llama.cpp server for local LLM inference with Vulkan GPU (RX 6700S)
254  # Router mode: serves multiple models with on-demand loading (--models-max 1)
255  # Only one model loaded in VRAM at a time; auto-swaps on request
256  # Benchmarks on RX 6700S: 7B dense ~40 tok/s, 3B active MoE ~20-40 tok/s (Vulkan, Q4_K_M)
257  systemd.services.llama-cpp =
258    let
259      llama-cpp-vulkan = pkgs.llama-cpp.override { vulkanSupport = true; };
260
261      # Model preset INI for router mode
262      # Section names become model IDs in the API (used in models.json)
263      modelsPreset = pkgs.writeText "llama-models.ini" ''
264        version = 1
265
266        ; Shared settings for all models
267        [*]
268        n-gpu-layers = 99
269        jinja = true
270
271        ; === Coding models ===
272
273        ; Qwen3 Coder Next 80B MoE (3B active) — best coding model, needs ~48GB RAM
274        [Qwen/Qwen3-Coder-Next-GGUF:Q3_K_M]
275
276        ; Qwen3 Coder 30B MoE (3B active) — sweet spot coding, ~19GB RAM
277        [Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M]
278
279        ; Qwen 2.5 Coder 7B dense — lightweight coding, ~5GB (current default)
280        [Qwen/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M]
281
282        ; === General purpose models ===
283
284        ; Qwen3 8B dense — best all-rounder (reasoning, tool use, multilingual)
285        [Qwen/Qwen3-8B-GGUF:Q4_K_M]
286
287        ; DeepSeek R1 Distill 7B — deep reasoning / chain-of-thought
288        [bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF:Q4_K_M]
289
290        ; Phi-4 mini 3.8B — ultra-fast utility model
291        [unsloth/Phi-4-mini-instruct-GGUF:Q4_K_M]
292      '';
293
294    in
295    {
296      description = "llama.cpp server (Vulkan, router mode)";
297      after = [ "network.target" ];
298      # Disabled: not used for real work. Config kept for re-enablement.
299      # Re-enable by restoring wantedBy = [ "multi-user.target" ] and firewall port 8090.
300      wantedBy = [ ];
301
302      environment = {
303        GGML_VK_VISIBLE_DEVICES = "0"; # Use RX 6700S dGPU only
304        HIP_VISIBLE_DEVICES = ""; # Disable ROCm to avoid conflicts
305      };
306
307      serviceConfig = {
308        Type = "idle";
309        KillSignal = "SIGINT";
310        ExecStart = builtins.concatStringsSep " " [
311          "${llama-cpp-vulkan}/bin/llama-server"
312          "--log-disable"
313          "--host 0.0.0.0"
314          "--port 8090"
315          "--models-preset ${modelsPreset}"
316          "--models-max 1"
317          "-np 1"
318          "--api-key local-test"
319        ];
320        Restart = "on-failure";
321        RestartSec = 30;
322
323        # Run as vincent to access HuggingFace model cache
324        User = "vincent";
325        Group = "users";
326
327        # GPU access requires relaxed sandboxing
328        PrivateDevices = false;
329        ProtectHome = false;
330        MemoryDenyWriteExecute = false;
331      };
332    };
333
334  # Oneshot service to pre-download all LLM models
335  # Run manually: sudo systemctl start llama-download-models
336  systemd.services.llama-download-models =
337    let
338      llama-cpp-vulkan = pkgs.llama-cpp.override { vulkanSupport = true; };
339    in
340    {
341      description = "Download LLM models for llama.cpp";
342      after = [ "network-online.target" ];
343      wants = [ "network-online.target" ];
344
345      serviceConfig = {
346        Type = "oneshot";
347        RemainAfterExit = false;
348        User = "vincent";
349        Group = "users";
350        ExecStart = pkgs.writeShellScript "llama-download-models" ''
351          set -euo pipefail
352          export PATH="${llama-cpp-vulkan}/bin:$PATH"
353
354          models=(
355            "Qwen/Qwen3-Coder-Next-GGUF:Q3_K_M"
356            "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M"
357            "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M"
358            "Qwen/Qwen3-8B-GGUF:Q4_K_M"
359            "bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF:Q4_K_M"
360            "unsloth/Phi-4-mini-instruct-GGUF:Q4_K_M"
361          )
362
363          for model in "''${models[@]}"; do
364            echo "Checking/downloading $model..."
365            # Use llama-cli to trigger download, then exit immediately
366            timeout 120 llama-cli -hf "$model" -p "test" -n 1 --no-display-prompt 2>&1 || true
367            echo "Done: $model"
368          done
369        '';
370      };
371    };
372
373  # System packages for LLM, gaming, and tools
374  environment.systemPackages = with pkgs; [
375    # LLM tools (same package as the service, for CLI use)
376    (llama-cpp.override {
377      vulkanSupport = true;
378    })
379
380    # GPU monitoring and management
381    radeontop # GPU usage monitor
382    clinfo # Check OpenCL
383    vulkan-tools # vulkaninfo
384    rocmPackages.rocminfo
385    asusctl # CLI for supergfxctl
386    supergfxctl
387
388    # Gaming tools
389    mangohud # FPS and performance overlay
390    goverlay # MangoHud configuration GUI
391    protonup-qt # Install custom Proton versions (Proton-GE)
392    protontricks # Winetricks for Proton prefixes
393    heroic # GOG/Epic Games launcher
394
395    # Development
396    python3
397    uv
398
399    # Audio utilities for G14
400    alsa-utils # amixer, alsactl for AMP control
401    jamesdsp # DSP/EQ to replace Dolby Atmos (use with asus-jamesdsp presets)
402  ];
403
404  # Prometheus node exporter (configured in common module)
405  # Port and basic collectors already set in ../common/base (prometheus-exporters-node.nix)
406
407  # Keep dGPU (Navi 23 / RX 6700S) awake so HDMI hotplug is detected.
408  # The HDMI port is wired to the dGPU; runtime suspend prevents detection.
409  services.udev.extraRules = ''
410    ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x1002", ATTR{device}=="0x73ef", ATTR{power/control}="on"
411  '';
412
413  # Lid handling: ignore (desktop replacement, mostly on AC)
414  services.logind.settings.Login = {
415    HandleLidSwitch = "ignore";
416    HandleLidSwitchExternalPower = "ignore";
417    HandleLidSwitchDocked = "ignore";
418  };
419
420  # ASUS G14 2022 Audio Fix
421  # Sets ALSA mixer levels at boot and after resume
422  systemd.services.asus-g14-audio-fix = {
423    description = "ASUS G14 Audio Amplifier Initialization";
424    after = [
425      "sound.target"
426      "alsa-restore.service"
427    ];
428    wantedBy = [
429      "multi-user.target"
430      "post-resume.target"
431    ];
432
433    serviceConfig = {
434      Type = "oneshot";
435      RemainAfterExit = true;
436      ExecStart = pkgs.writeShellScript "g14-audio-init" ''
437        # Find the ALC285 audio card (internal speakers)
438        CARD=$(${pkgs.alsa-utils}/bin/aplay -l | grep "ALC285" | head -1 | sed 's/card \([0-9]\).*/\1/')
439
440        if [ -z "$CARD" ]; then
441          echo "ERROR: Could not find ALC285 audio card"
442          exit 1
443        fi
444
445        echo "Initializing G14 audio on card $CARD"
446
447        # Set Master volume to 100% and unmute
448        ${pkgs.alsa-utils}/bin/amixer -c "$CARD" sset Master 100% unmute || true
449
450        # Set Speaker volume (tweeters)
451        ${pkgs.alsa-utils}/bin/amixer -c "$CARD" sset Speaker 100% unmute || true
452
453        # Set Bass Speaker (subwoofers)
454        ${pkgs.alsa-utils}/bin/amixer -c "$CARD" sset 'Bass Speaker' unmute || true
455
456        # Set Headphone volume
457        ${pkgs.alsa-utils}/bin/amixer -c "$CARD" sset Headphone 100% unmute || true
458
459        echo "G14 audio initialization complete"
460      '';
461    };
462  };
463
464  services.udev.packages = [ pkgs.sane-airscan ];
465  hardware.sane = {
466    enable = true;
467    extraBackends = [ pkgs.sane-airscan ];
468    openFirewall = true;
469    netConf = "192.168.12.70";
470  };
471}