mirror of
https://github.com/oqyude/nixos.git
synced 2026-10-07 20:47:14 +03:00
Compare commits
20
Commits
417c7abda6
...
14c91e68a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c91e68a4 | ||
|
|
c73a698857 | ||
|
|
c8d4a12a73 | ||
|
|
c854b2cc6d | ||
|
|
22a19be1b6 | ||
|
|
99747849d3 | ||
|
|
b88c8ebce0 | ||
|
|
cc20ee637d | ||
|
|
95ba7c2903 | ||
|
|
b0191bc7d1 | ||
|
|
543fcc61d9 | ||
|
|
0b9ac53b71 | ||
|
|
b476cace8e | ||
|
|
2de80a356b | ||
|
|
fb56f6310b | ||
|
|
1c77ae658e | ||
|
|
509fd3dde0 | ||
|
|
958247b22c | ||
|
|
c05cc88843 | ||
|
|
d49fd5a358 |
+3
-1
@@ -1,2 +1,4 @@
|
||||
.vscode
|
||||
.omo
|
||||
.omo
|
||||
__pycache__
|
||||
scripts
|
||||
+12
-15
@@ -1,18 +1,15 @@
|
||||
# Host: "default" (device: minimal)
|
||||
#
|
||||
# The host record lives in configurations/default.nix; this file is only the
|
||||
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||
{
|
||||
deviceType = "minimal";
|
||||
modules = [
|
||||
(
|
||||
{
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
inputs.self.nixosModules.default
|
||||
];
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
)
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
inputs.self.nixosModules.default
|
||||
];
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
|
||||
@@ -1,18 +1,73 @@
|
||||
{ inputs, ... }@flakeContext:
|
||||
let
|
||||
lib = inputs.nixpkgs.lib;
|
||||
mkSystem = import ../lib/mkSystem.nix flakeContext;
|
||||
xlibLib = import ../lib/xlib { inherit lib; };
|
||||
|
||||
# One record per host. The attribute name IS the hostname, so it is written
|
||||
# exactly once; `hostname` is only needed where the attribute name is not
|
||||
# the real hostname (the `default` entry).
|
||||
#
|
||||
# device device type, must be a key of `devices` in lib/xlib/device.nix
|
||||
# modules module body for this host
|
||||
hosts = {
|
||||
default = {
|
||||
hostname = "nixos";
|
||||
device = "minimal";
|
||||
modules = [ ./any.nix ];
|
||||
};
|
||||
atoridu = {
|
||||
device = "primary";
|
||||
modules = [ ./mini-pc.nix ];
|
||||
};
|
||||
rydiwo = {
|
||||
device = "secondary";
|
||||
modules = [ ./mini-laptop.nix ];
|
||||
};
|
||||
otreca = {
|
||||
device = "vds";
|
||||
modules = [ ./vds.nix ];
|
||||
};
|
||||
sapphira = {
|
||||
device = "server";
|
||||
modules = [ ./server.nix ];
|
||||
};
|
||||
wsl = {
|
||||
device = "wsl";
|
||||
modules = [ ./wsl.nix ];
|
||||
};
|
||||
};
|
||||
|
||||
mkHost =
|
||||
name:
|
||||
{
|
||||
device,
|
||||
modules,
|
||||
hostname ? name,
|
||||
...
|
||||
}:
|
||||
let
|
||||
xlib = xlibLib.mkXlib {
|
||||
inherit hostname;
|
||||
type = device;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit xlib;
|
||||
system = mkSystem { inherit xlib modules; };
|
||||
};
|
||||
in
|
||||
{
|
||||
nixosConfigurations = {
|
||||
default = mkSystem (import ./any.nix); # default
|
||||
atoridu = mkSystem (import ./mini-pc.nix); # atoridu
|
||||
rydiwo = mkSystem (import ./mini-laptop.nix); # rydiwo
|
||||
otreca = mkSystem (import ./vds.nix); # vds
|
||||
sapphira = mkSystem (import ./server.nix); # sapphira
|
||||
wsl = mkSystem (import ./wsl.nix); # wsl
|
||||
};
|
||||
nixosConfigurations = lib.mapAttrs' (
|
||||
name: spec: lib.nameValuePair name (mkHost name spec).system
|
||||
) hosts;
|
||||
|
||||
# Per-host xlib values, for code that lives outside the module system
|
||||
# (deploy, overlays, pkgs).
|
||||
xlib = lib.mapAttrs' (name: spec: lib.nameValuePair name (mkHost name spec).xlib) hosts;
|
||||
|
||||
nixOnDroidConfigurations = {
|
||||
epral = import ./mobile.nix flakeContext; # epral (Android via nix-on-droid)
|
||||
epral = import ./mobile.nix flakeContext; # epral (Android device via nix-on-droid)
|
||||
# Alias so a plain `nix-on-droid switch` from a local clone
|
||||
# (~/.config/nix-on-droid) picks up the device config without `#epral`.
|
||||
default = import ./mobile.nix flakeContext;
|
||||
|
||||
@@ -1,41 +1,37 @@
|
||||
# Host: "rydiwo" (device: secondary)
|
||||
#
|
||||
# The host record lives in configurations/default.nix; this file is only the
|
||||
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||
{
|
||||
deviceType = "secondary";
|
||||
hostname = "rydiwo";
|
||||
modules = [
|
||||
(
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = with inputs; [
|
||||
nixos-hardware.nixosModules.chuwi-minibook-x
|
||||
./hardware/mini-laptop.nix
|
||||
self.nixosModules.default
|
||||
];
|
||||
|
||||
boot = {
|
||||
kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable;
|
||||
loader = {
|
||||
systemd-boot.enable = lib.mkDefault true;
|
||||
efi.canTouchEfiVariables = lib.mkDefault true;
|
||||
};
|
||||
};
|
||||
|
||||
fileSystems = xlib.helpers.mkNtfsMount {
|
||||
path = xlib.dirs.lamet-drive;
|
||||
uuid = "DC76BD3576BD116E";
|
||||
mask = "0000";
|
||||
};
|
||||
|
||||
xlib.ssh.enable = true;
|
||||
hardware.intel-gpu-tools.enable = true;
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
)
|
||||
lib,
|
||||
pkgs,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = with inputs; [
|
||||
nixos-hardware.nixosModules.chuwi-minibook-x
|
||||
./hardware/mini-laptop.nix
|
||||
self.nixosModules.default
|
||||
];
|
||||
|
||||
boot = {
|
||||
kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable;
|
||||
loader = {
|
||||
systemd-boot.enable = lib.mkDefault true;
|
||||
efi.canTouchEfiVariables = lib.mkDefault true;
|
||||
};
|
||||
};
|
||||
|
||||
fileSystems = xlib.helpers.mkNtfsMount {
|
||||
path = xlib.dirs.lamet-drive;
|
||||
uuid = "DC76BD3576BD116E";
|
||||
mask = "0000";
|
||||
};
|
||||
|
||||
host.ssh.enable = true;
|
||||
hardware.intel-gpu-tools.enable = true;
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
|
||||
+81
-80
@@ -1,83 +1,84 @@
|
||||
# Host: "atoridu" (device: primary)
|
||||
#
|
||||
# The host record lives in configurations/default.nix; this file is only the
|
||||
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||
{
|
||||
deviceType = "primary";
|
||||
hostname = "atoridu";
|
||||
modules = [
|
||||
(
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = with inputs; [
|
||||
./hardware/mini-pc.nix
|
||||
./disko/mini-pc.nix
|
||||
./hardware/logitech.nix
|
||||
self.nixosModules.default
|
||||
];
|
||||
|
||||
fileSystems = lib.listToAttrs (
|
||||
map (xlib.helpers.mkNtfsMount) [
|
||||
{
|
||||
path = xlib.dirs.therima-drive;
|
||||
uuid = "C0A2DDEFA2DDEA44";
|
||||
enable = false;
|
||||
}
|
||||
{
|
||||
path = xlib.dirs.vetymae-drive;
|
||||
uuid = "6408433908430A0E";
|
||||
enable = false;
|
||||
}
|
||||
{
|
||||
path = xlib.dirs.soptur-drive;
|
||||
uuid = "C00C56E40C56D54E";
|
||||
enable = false;
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
boot = {
|
||||
kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable;
|
||||
loader = {
|
||||
systemd-boot.enable = lib.mkDefault true;
|
||||
efi.canTouchEfiVariables = lib.mkDefault true;
|
||||
};
|
||||
};
|
||||
|
||||
services.xserver = {
|
||||
videoDrivers = [
|
||||
"amdgpu"
|
||||
];
|
||||
};
|
||||
services.pipewire = {
|
||||
enable = lib.mkDefault true;
|
||||
systemWide = true;
|
||||
alsa.enable = false;
|
||||
alsa.support32Bit = true;
|
||||
pulse.enable = true;
|
||||
jack.enable = true;
|
||||
extraConfig.pipewire = {
|
||||
"99-default.conf" = {
|
||||
"context.properties" = {
|
||||
"default.clock.rate" = 96000;
|
||||
"default.clock.allowed-rates" = [
|
||||
44100
|
||||
48000
|
||||
96000
|
||||
];
|
||||
"default.clock.quantum" = 1024;
|
||||
"default.clock.min-quantum" = 256;
|
||||
"default.clock.max-quantum" = 2048;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
nixpkgs.config.pulseaudio = true;
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
)
|
||||
lib,
|
||||
pkgs,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = with inputs; [
|
||||
./hardware/mini-pc.nix
|
||||
./disko/mini-pc.nix
|
||||
./hardware/logitech.nix
|
||||
self.nixosModules.default
|
||||
];
|
||||
|
||||
# mkNtfsMount returns a `{ "<path>" = { ... }; }` attrset (the shape
|
||||
# fileSystems itself wants), so several mounts are combined with
|
||||
# mergeAttrsList — not listToAttrs, which would demand `name`/`value`.
|
||||
#
|
||||
# These three ntfs3 drives are intentionally left unmounted. The entries
|
||||
# are kept commented out rather than deleted, so restoring a drive is a
|
||||
# matter of uncommenting its block. `enable = false` would declare a drive
|
||||
# without mounting it; dropping the field mounts it.
|
||||
fileSystems = lib.mergeAttrsList (
|
||||
map (xlib.helpers.mkNtfsMount) [
|
||||
# {
|
||||
# path = xlib.dirs.therima-drive;
|
||||
# uuid = "C0A2DDEFA2DDEA44";
|
||||
# }
|
||||
# {
|
||||
# path = xlib.dirs.vetymae-drive;
|
||||
# uuid = "6408433908430A0E";
|
||||
# }
|
||||
# {
|
||||
# path = xlib.dirs.soptur-drive;
|
||||
# uuid = "C00C56E40C56D54E";
|
||||
# }
|
||||
]
|
||||
);
|
||||
|
||||
boot = {
|
||||
kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable;
|
||||
loader = {
|
||||
systemd-boot.enable = lib.mkDefault true;
|
||||
efi.canTouchEfiVariables = lib.mkDefault true;
|
||||
};
|
||||
};
|
||||
|
||||
services.xserver = {
|
||||
videoDrivers = [
|
||||
"amdgpu"
|
||||
];
|
||||
};
|
||||
services.pipewire = {
|
||||
enable = lib.mkDefault true;
|
||||
systemWide = true;
|
||||
alsa.enable = false;
|
||||
alsa.support32Bit = true;
|
||||
pulse.enable = true;
|
||||
jack.enable = true;
|
||||
extraConfig.pipewire = {
|
||||
"99-default.conf" = {
|
||||
"context.properties" = {
|
||||
"default.clock.rate" = 96000;
|
||||
"default.clock.allowed-rates" = [
|
||||
44100
|
||||
48000
|
||||
96000
|
||||
];
|
||||
"default.clock.quantum" = 1024;
|
||||
"default.clock.min-quantum" = 256;
|
||||
"default.clock.max-quantum" = 2048;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
nixpkgs.config.pulseaudio = true;
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ let
|
||||
# (essentials, users.nix, home-manager, sops-nix, disko, grub2-themes)
|
||||
# and nixpkgs.overlays are skipped so it evaluates under nix-on-droid's
|
||||
# module system (class = "nixOnDroid").
|
||||
xlib = import ../lib/xlib.nix { lib = inputs.nixpkgs.lib; };
|
||||
nixOnDroidModule =
|
||||
{
|
||||
lib,
|
||||
@@ -21,20 +22,15 @@ let
|
||||
inputs.self.nixosModules.strict
|
||||
];
|
||||
|
||||
xlib.device = {
|
||||
type = "termux";
|
||||
hostname = "epral";
|
||||
};
|
||||
|
||||
# Login shell. nix-on-droid writes /etc/passwd from user.shell on every
|
||||
# activation, so `chsh` is useless here — set it in nix instead.
|
||||
# (default is bashInteractive)
|
||||
user.shell = "${pkgs.zsh}/bin/zsh";
|
||||
|
||||
# SSH user (matches `User oqyude` in the client's ~/.ssh/config).
|
||||
# SSH user (matches the `User` entries in the client's ~/.ssh/config).
|
||||
# Default is "nix-on-droid"; home stays at the read-only
|
||||
# /data/data/com.termux.nix/files/home either way.
|
||||
user.userName = "oqyude";
|
||||
user.userName = xlib.device.username;
|
||||
|
||||
# Minimal termux settings (nix-on-droid options only:
|
||||
# environment.*, nix.*, time.*, user.*, system.*, android-integration.*)
|
||||
@@ -120,6 +116,13 @@ inputs.nix-on-droid.lib.nixOnDroidConfiguration {
|
||||
nixOnDroidModule
|
||||
];
|
||||
extraSpecialArgs = {
|
||||
deviceType = "termux";
|
||||
# `xlib` is the same value shape NixOS hosts get (lib/mkSystem.nix);
|
||||
# the hostname lives here because nixOnDroidConfigurations is keyed by
|
||||
# both "epral" and the "default" alias, so it cannot come from the
|
||||
# attribute name.
|
||||
xlib = xlib.mkXlib {
|
||||
hostname = "epral";
|
||||
type = "termux";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+136
-80
@@ -1,83 +1,139 @@
|
||||
# Host: "sapphira" (device: server)
|
||||
#
|
||||
# The host record lives in configurations/default.nix; this file is only the
|
||||
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||
{
|
||||
deviceType = "server";
|
||||
hostname = "sapphira";
|
||||
modules = [
|
||||
(
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
./hardware/server.nix
|
||||
inputs.self.nixosModules.default
|
||||
];
|
||||
|
||||
boot = {
|
||||
# kernelPackages = pkgs.linuxPackages_xanmod_stable;
|
||||
hardwareScan = true;
|
||||
loader = {
|
||||
systemd-boot.enable = lib.mkDefault true;
|
||||
efi.canTouchEfiVariables = lib.mkDefault true;
|
||||
};
|
||||
};
|
||||
|
||||
hardware = {
|
||||
bluetooth.enable = true;
|
||||
graphics = {
|
||||
enable = true;
|
||||
extraPackages = with pkgs; [
|
||||
intel-media-driver
|
||||
intel-ocl
|
||||
intel-vaapi-driver
|
||||
];
|
||||
};
|
||||
intel-gpu-tools.enable = true;
|
||||
};
|
||||
|
||||
fileSystems =
|
||||
(xlib.helpers.mkExfatMount {
|
||||
path = xlib.dirs.archive-drive;
|
||||
label = "archive";
|
||||
})
|
||||
// (xlib.helpers.mkExfatMount {
|
||||
path = xlib.dirs.mobile-drive;
|
||||
uuid = "7EB1-DC99";
|
||||
})
|
||||
// (xlib.helpers.mkBindMount {
|
||||
what = xlib.dirs.services-folder;
|
||||
where = xlib.dirs.services-mnt-folder;
|
||||
})
|
||||
// {
|
||||
# External drive
|
||||
"${xlib.dirs.server-home}" = {
|
||||
device = "/dev/disk/by-uuid/37e53ebc-5343-a94d-9fe2-0ca39e13a8de";
|
||||
fsType = "ext4";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"z ${xlib.dirs.services-mnt-folder} 0777 root root -"
|
||||
];
|
||||
|
||||
xlib.ssh.enable = true;
|
||||
|
||||
networking = {
|
||||
networkmanager.enable = true;
|
||||
firewall.enable = false;
|
||||
# nameservers = [
|
||||
# "192.168.1.1"
|
||||
# "127.0.0.1"
|
||||
# ];
|
||||
};
|
||||
|
||||
system = {
|
||||
stateVersion = "25.05";
|
||||
};
|
||||
}
|
||||
)
|
||||
lib,
|
||||
pkgs,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
./hardware/server.nix
|
||||
inputs.self.nixosModules.default
|
||||
];
|
||||
|
||||
boot = {
|
||||
# kernelPackages = pkgs.linuxPackages_xanmod_stable;
|
||||
hardwareScan = true;
|
||||
loader = {
|
||||
systemd-boot.enable = lib.mkDefault true;
|
||||
efi.canTouchEfiVariables = lib.mkDefault true;
|
||||
};
|
||||
};
|
||||
|
||||
hardware = {
|
||||
bluetooth.enable = true;
|
||||
graphics = {
|
||||
enable = true;
|
||||
extraPackages = with pkgs; [
|
||||
intel-media-driver
|
||||
intel-ocl
|
||||
intel-vaapi-driver
|
||||
];
|
||||
};
|
||||
intel-gpu-tools.enable = true;
|
||||
};
|
||||
|
||||
fileSystems =
|
||||
(xlib.helpers.mkExfatMount {
|
||||
path = xlib.dirs.archive-drive;
|
||||
label = "archive";
|
||||
})
|
||||
// (xlib.helpers.mkExfatMount {
|
||||
path = xlib.dirs.mobile-drive;
|
||||
uuid = "7EB1-DC99";
|
||||
})
|
||||
// (xlib.helpers.mkBindMount {
|
||||
what = xlib.dirs.services-folder;
|
||||
where = xlib.dirs.services-mnt-folder;
|
||||
})
|
||||
// {
|
||||
# External drive
|
||||
"${xlib.dirs.server-home}" = {
|
||||
device = "/dev/disk/by-uuid/37e53ebc-5343-a94d-9fe2-0ca39e13a8de";
|
||||
fsType = "ext4";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"z ${xlib.dirs.services-mnt-folder} 0777 root root -"
|
||||
];
|
||||
|
||||
host.ssh.enable = true;
|
||||
|
||||
# Offload Nix builds to the WSL2 NixOS instance running on vetymae
|
||||
# (Windows 11 host). Sapphira only has 2 logical cores; the WSL exposes
|
||||
# 24 cores + 14 GiB. The matchBlock with ProxyCommand is generated by
|
||||
# modules/server/builder.nix, the other side of the same option lives in
|
||||
# modules/wsl/builder.nix.
|
||||
#
|
||||
# `proxyCommand` is what marks this builder as needing the SSH matchBlock
|
||||
# (see modules/server/builder.nix). A builder reachable directly would
|
||||
# omit it.
|
||||
#
|
||||
# ---- DISABLED 2026-10-04 ----
|
||||
# Remote building temporarily turned off coordinator-side. `host.builder.clients`
|
||||
# falls back to its default `[]` (declared in modules/options.nix), so
|
||||
# modules/server/builder.nix's `lib.mkIf (clients != [])` never fires and no
|
||||
# buildMachines / SSH blocks / distributedBuilds override get generated.
|
||||
# All builds run locally on sapphira's 2 cores. Re-enable by removing the
|
||||
# Nix comments on the block below (and on `host.builder.enable = true;`
|
||||
# in configurations/wsl.nix).
|
||||
#
|
||||
# host.builder.clients = [
|
||||
# {
|
||||
# hostName = "vetymae-nix";
|
||||
# sshUser = "oqyude";
|
||||
# sshKey = "/root/.ssh/id_ed25519";
|
||||
# # NixOS calls this `systems` (plural), not `systemTypes`. The
|
||||
# # default is empty — every derivation is rejected. The WSL NixOS
|
||||
# # runs on x86_64-linux, matching sapphira.
|
||||
# systems = [ "x86_64-linux" ];
|
||||
# # vetymae-nix drops kvm + nixos-test from its advertised
|
||||
# # system-features (see modules/wsl/builder.nix). Listing them here
|
||||
# # would not break anything (Nix intersects), but listing the
|
||||
# # features the WSL actually has is the documented contract.
|
||||
# supportedFeatures = [
|
||||
# "benchmark"
|
||||
# "big-parallel"
|
||||
# ];
|
||||
# mandatoryFeatures = [ ];
|
||||
# maxJobs = 24;
|
||||
# speedFactor = 0.5;
|
||||
# # Keep the SSH session alive across many small builds in one daemon
|
||||
# # session — compile-heavy workloads spam the daemon with hundreds of
|
||||
# # derivations and ControlMaster collapses those into one Windows hop.
|
||||
# # NB: `nix.buildMachines` has no `sshOptions` attribute, so the
|
||||
# # ControlMaster directive lives in the SSH matchBlock instead (see
|
||||
# # modules/server/builder.nix).
|
||||
# #
|
||||
# # The OpenSSH alias for this host (matches the user's
|
||||
# # ~/.ssh/config so known_hosts entries do not collide with the
|
||||
# # Windows OpenSSH entry on 127.0.0.1/vetymae) is consumed only by
|
||||
# # the SSH matchBlock below — not by `nix.buildMachines`, which has
|
||||
# # no such attribute.
|
||||
# hostKeyAlias = "wsl-nixos-on-vetymae";
|
||||
# # Use the Windows host's IP directly so the nix-daemon (running as
|
||||
# # root, without the user's ~/.ssh/config) does not need a separate
|
||||
# # `vetymae` host alias. With StrictHostKeyChecking=accept-new the
|
||||
# # first connection adds the Windows host key to /root/.ssh/known_hosts.
|
||||
# proxyCommand = "ssh oqyude@192.168.1.100 'wsl -d NixOS -- nc 127.0.0.1 22'";
|
||||
# }
|
||||
# ];
|
||||
|
||||
networking = {
|
||||
networkmanager.enable = true;
|
||||
firewall.enable = false;
|
||||
# nameservers = [
|
||||
# "192.168.1.1"
|
||||
# "127.0.0.1"
|
||||
# ];
|
||||
};
|
||||
|
||||
system = {
|
||||
stateVersion = "25.05";
|
||||
};
|
||||
}
|
||||
|
||||
+117
-117
@@ -1,122 +1,122 @@
|
||||
# Host: "otreca" (device: vds)
|
||||
#
|
||||
# The host record lives in configurations/default.nix; this file is only the
|
||||
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||
{
|
||||
deviceType = "vds";
|
||||
hostname = "otreca";
|
||||
modules = [
|
||||
(
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
modulesPath,
|
||||
pkgs,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/installer/scan/not-detected.nix")
|
||||
(modulesPath + "/profiles/qemu-guest.nix")
|
||||
lib,
|
||||
modulesPath,
|
||||
pkgs,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/installer/scan/not-detected.nix")
|
||||
(modulesPath + "/profiles/qemu-guest.nix")
|
||||
|
||||
./disko/vds.nix
|
||||
./hardware/vds.nix
|
||||
./disko/vds.nix
|
||||
./hardware/vds.nix
|
||||
|
||||
inputs.self.nixosModules.default
|
||||
];
|
||||
|
||||
boot = {
|
||||
# kernelPackages = pkgs.linuxPackages_xanmod_stable;
|
||||
hardwareScan = true;
|
||||
loader = {
|
||||
grub = {
|
||||
enable = true;
|
||||
device = "nodev";
|
||||
useOSProber = false;
|
||||
efiSupport = false;
|
||||
};
|
||||
systemd-boot.enable = lib.mkDefault false;
|
||||
};
|
||||
kernel.sysctl = {
|
||||
"net.ipv4.tcp_syncookies" = 1;
|
||||
"net.ipv4.tcp_max_syn_backlog" = 4096;
|
||||
"net.ipv4.tcp_synack_retries" = 3;
|
||||
"net.ipv4.tcp_syn_retries" = 3;
|
||||
};
|
||||
};
|
||||
|
||||
xlib.ssh.enable = true;
|
||||
services.openssh.openFirewall = true;
|
||||
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
};
|
||||
networking = {
|
||||
nameservers = [
|
||||
"1.1.1.1"
|
||||
"8.8.8.8"
|
||||
];
|
||||
networkmanager.enable = true;
|
||||
tempAddresses = "disabled";
|
||||
dhcpcd = {
|
||||
enable = true;
|
||||
IPv6rs = false;
|
||||
};
|
||||
firewall = {
|
||||
enable = true;
|
||||
allowPing = true;
|
||||
};
|
||||
nftables = {
|
||||
enable = true;
|
||||
ruleset = ''
|
||||
table inet filter {
|
||||
chain input {
|
||||
type filter hook input priority 0;
|
||||
|
||||
# loopback
|
||||
iif lo accept
|
||||
|
||||
# уже установленные
|
||||
ct state established,related accept
|
||||
|
||||
# РЕЖЕМ SYN СРАЗУ
|
||||
tcp flags syn tcp dport {80,443} limit rate 20/second burst 40 packets accept
|
||||
tcp flags syn tcp dport {80,443} drop
|
||||
|
||||
# остальное по необходимости
|
||||
}
|
||||
}
|
||||
'';
|
||||
};
|
||||
enableIPv6 = false;
|
||||
interfaces.ens3 = {
|
||||
useDHCP = true;
|
||||
# ipv4.addresses = [
|
||||
# {
|
||||
# address = "31.57.158.109";
|
||||
# prefixLength = 24;
|
||||
# }
|
||||
# ];
|
||||
# ipv6.addresses = [
|
||||
# {
|
||||
# address = "2a13:7c00:6:102:f816:3eff:fe91:6b9e";
|
||||
# prefixLength = 64;
|
||||
# }
|
||||
# ];
|
||||
};
|
||||
# defaultGateway = {
|
||||
# address = "31.57.158.1";
|
||||
# interface = "ens3";
|
||||
# };
|
||||
# defaultGateway6 = {
|
||||
# address = "2a13:7c00:6:102::1";
|
||||
# interface = "ens3";
|
||||
# };
|
||||
};
|
||||
|
||||
system = {
|
||||
stateVersion = "25.05";
|
||||
};
|
||||
}
|
||||
)
|
||||
inputs.self.nixosModules.default
|
||||
];
|
||||
|
||||
boot = {
|
||||
# kernelPackages = pkgs.linuxPackages_xanmod_stable;
|
||||
hardwareScan = true;
|
||||
loader = {
|
||||
grub = {
|
||||
enable = true;
|
||||
device = "nodev";
|
||||
useOSProber = false;
|
||||
efiSupport = false;
|
||||
};
|
||||
systemd-boot.enable = lib.mkDefault false;
|
||||
};
|
||||
kernel.sysctl = {
|
||||
"net.ipv4.tcp_syncookies" = 1;
|
||||
"net.ipv4.tcp_max_syn_backlog" = 4096;
|
||||
"net.ipv4.tcp_synack_retries" = 3;
|
||||
"net.ipv4.tcp_syn_retries" = 3;
|
||||
};
|
||||
};
|
||||
|
||||
host.ssh.enable = true;
|
||||
# SSH is reachable only over Tailscale (not on the public internet).
|
||||
# This otreca VDS is reached by deploy-rs and by oqyude over the
|
||||
# tailnet, so exposing 22 to ens3 is pure attack surface.
|
||||
services.openssh.openFirewall = false;
|
||||
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
};
|
||||
# Open port 22 only on the tailscale interface.
|
||||
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ 22 ];
|
||||
networking = {
|
||||
nameservers = [
|
||||
"1.1.1.1"
|
||||
"8.8.8.8"
|
||||
];
|
||||
networkmanager.enable = true;
|
||||
tempAddresses = "disabled";
|
||||
dhcpcd = {
|
||||
enable = true;
|
||||
IPv6rs = false;
|
||||
};
|
||||
firewall = {
|
||||
enable = true;
|
||||
allowPing = true;
|
||||
};
|
||||
nftables = {
|
||||
enable = true;
|
||||
ruleset = ''
|
||||
table inet filter {
|
||||
chain input {
|
||||
type filter hook input priority 0;
|
||||
|
||||
# loopback
|
||||
iif lo accept
|
||||
|
||||
# уже установленные
|
||||
ct state established,related accept
|
||||
|
||||
# РЕЖЕМ SYN СРАЗУ
|
||||
tcp flags syn tcp dport {80,443} limit rate 20/second burst 40 packets accept
|
||||
tcp flags syn tcp dport {80,443} drop
|
||||
|
||||
# остальное по необходимости
|
||||
}
|
||||
}
|
||||
'';
|
||||
};
|
||||
enableIPv6 = false;
|
||||
interfaces.ens3 = {
|
||||
useDHCP = true;
|
||||
# ipv4.addresses = [
|
||||
# {
|
||||
# address = "31.57.158.109";
|
||||
# prefixLength = 24;
|
||||
# }
|
||||
# ];
|
||||
# ipv6.addresses = [
|
||||
# {
|
||||
# address = "2a13:7c00:6:102:f816:3eff:fe91:6b9e";
|
||||
# prefixLength = 64;
|
||||
# }
|
||||
# ];
|
||||
};
|
||||
# defaultGateway = {
|
||||
# address = "31.57.158.1";
|
||||
# interface = "ens3";
|
||||
# };
|
||||
# defaultGateway6 = {
|
||||
# address = "2a13:7c00:6:102::1";
|
||||
# interface = "ens3";
|
||||
# };
|
||||
};
|
||||
|
||||
system = {
|
||||
stateVersion = "25.05";
|
||||
};
|
||||
}
|
||||
|
||||
+57
-41
@@ -1,44 +1,60 @@
|
||||
# Host: "wsl" (device: wsl)
|
||||
#
|
||||
# The host record lives in configurations/default.nix; this file is only the
|
||||
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||
{
|
||||
deviceType = "wsl";
|
||||
hostname = "wsl";
|
||||
modules = [
|
||||
(
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
modulesPath,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
inputs.nixos-wsl.nixosModules.default
|
||||
inputs.self.nixosModules.default
|
||||
];
|
||||
|
||||
hardware = {
|
||||
graphics.enable = true;
|
||||
};
|
||||
|
||||
networking = {
|
||||
firewall = {
|
||||
enable = false;
|
||||
allowPing = true;
|
||||
};
|
||||
enableIPv6 = true;
|
||||
};
|
||||
|
||||
wsl = {
|
||||
enable = true;
|
||||
startMenuLaunchers = true;
|
||||
useWindowsDriver = true;
|
||||
defaultUser = config.xlib.device.username;
|
||||
};
|
||||
|
||||
system.stateVersion = "24.11";
|
||||
}
|
||||
)
|
||||
lib,
|
||||
modulesPath,
|
||||
pkgs,
|
||||
xlib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
inputs.nixos-wsl.nixosModules.default
|
||||
inputs.self.nixosModules.default
|
||||
];
|
||||
|
||||
hardware = {
|
||||
graphics.enable = true;
|
||||
};
|
||||
|
||||
networking = {
|
||||
firewall = {
|
||||
enable = false;
|
||||
allowPing = true;
|
||||
};
|
||||
enableIPv6 = true;
|
||||
};
|
||||
|
||||
wsl = {
|
||||
enable = true;
|
||||
startMenuLaunchers = true;
|
||||
useWindowsDriver = true;
|
||||
defaultUser = xlib.device.username;
|
||||
};
|
||||
|
||||
# Enable SSH server on WSL NixOS so sapphira can drive it directly via a
|
||||
# ProxyCommand chain through the Windows OpenSSH layer. The shared
|
||||
# essentials/ssh.nix module wires host keys, sops-managed user keys, and
|
||||
# passwordless key auth — nothing to repeat here.
|
||||
host.ssh.enable = true;
|
||||
|
||||
# Advertise this WSL instance as a remote Nix builder for sapphira (2
|
||||
# cores, the bottleneck host). All builder wiring — fixing the
|
||||
# `system-features` to drop the unsupported `kvm`, and adding the SSH
|
||||
# user `oqyude` to trusted-users — lives in modules/wsl/builder.nix.
|
||||
#
|
||||
# ---- DISABLED 2026-10-04 ----
|
||||
# Remote building temporarily turned off builder-side. The default of
|
||||
# `host.builder.enable` is `false` (modules/options.nix), so
|
||||
# modules/wsl/builder.nix's `lib.mkIf enable` block is skipped: WSL
|
||||
# keeps its default system-features and trusted-users, and no SSH-side
|
||||
# state changes. Re-enable by uncommenting the assignment below and
|
||||
# removing the DISABLED banner in configurations/server.nix.
|
||||
#
|
||||
# host.builder.enable = true;
|
||||
|
||||
system.stateVersion = "24.11";
|
||||
}
|
||||
|
||||
+3
-1
@@ -6,7 +6,9 @@ let
|
||||
path = inputs.deploy-rs.lib.x86_64-linux.activate.nixos inputs.self.nixosConfigurations.${hostname};
|
||||
};
|
||||
};
|
||||
user = "${inputs.self.nixosConfigurations.default.config.xlib.device.username}";
|
||||
# Login user for every deploy target. Read from the hoisted xlib instead of
|
||||
# digging through a built NixOS configuration.
|
||||
user = "${inputs.self.xlib.default.device.username}";
|
||||
server = "sapphira";
|
||||
vds = "otreca";
|
||||
mini-laptop = "rydiwo";
|
||||
|
||||
Generated
+1
-17
@@ -463,8 +463,7 @@
|
||||
"plasma-manager": "plasma-manager",
|
||||
"proxy-suite": "proxy-suite",
|
||||
"sops-nix": "sops-nix",
|
||||
"utils": "utils",
|
||||
"zeroq-credentials": "zeroq-credentials"
|
||||
"utils": "utils"
|
||||
}
|
||||
},
|
||||
"scss-reset": {
|
||||
@@ -577,21 +576,6 @@
|
||||
"repo": "zapret-discord-youtube",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"zeroq-credentials": {
|
||||
"locked": {
|
||||
"lastModified": 1772104025,
|
||||
"narHash": "sha256-tX5I2lkwbB1leoib6Ao/Et0B1GYrn3vxw4DkFYX8uyM=",
|
||||
"ref": "refs/heads/master",
|
||||
"rev": "511fc5446b502ff111020bda6d57261648d62333",
|
||||
"revCount": 75,
|
||||
"type": "git",
|
||||
"url": "ssh://git@github.com/oqyude/zeroq-credentials.git"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@github.com/oqyude/zeroq-credentials.git"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
description = "oqyude flake";
|
||||
inputs = {
|
||||
# My
|
||||
zeroq-credentials.url = "git+ssh://git@github.com/oqyude/zeroq-credentials.git"; # flake of creds
|
||||
|
||||
# nixpkgs
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
# nixpkgs-master.url = "github:NixOS/nixpkgs/master";
|
||||
|
||||
+1
-5
@@ -53,11 +53,7 @@ let
|
||||
imports = [
|
||||
(./. + "/${xlib.device.type}.nix")
|
||||
];
|
||||
headless = builtins.elem xlib.device.type [
|
||||
"server"
|
||||
"vds"
|
||||
"wsl"
|
||||
];
|
||||
headless = xlib.isHeadless;
|
||||
};
|
||||
};
|
||||
sharedModules = [
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
# Declarative OpenCode + oh-my-openagent (oh-my-opencode) plugin setup.
|
||||
#
|
||||
# Mirrors ~/.config/opencode/ on the current workstation.
|
||||
# Imported by home/server.nix (sapphira). Auto-enables programs.opencode.
|
||||
#
|
||||
# Three files this module owns on disk (via xdg.configFile):
|
||||
# ~/.config/opencode/opencode.json <- programs.opencode.settings
|
||||
# ~/.config/opencode/tui.json <- programs.opencode.tui
|
||||
# ~/.config/opencode/oh-my-openagent.json <- oh-my-openagent plugin config
|
||||
#
|
||||
# Override any field in the importing module if needed.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Body of ~/.config/opencode/oh-my-openagent.json.
|
||||
# Loaded by the oh-my-openagent opencode plugin on startup.
|
||||
ohMyOpenagentConfig = {
|
||||
"$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
|
||||
|
||||
agents = {
|
||||
sisyphus = {
|
||||
model = "opencode/claude-opus-5";
|
||||
variant = "max";
|
||||
fallback_models = [
|
||||
{ model = "opencode/kimi-k3"; }
|
||||
{
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "medium";
|
||||
}
|
||||
{ model = "opencode/glm-5"; }
|
||||
{ model = "opencode/big-pickle"; }
|
||||
];
|
||||
};
|
||||
hephaestus = {
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "medium";
|
||||
};
|
||||
oracle = {
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "xhigh";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/gemini-3.1-pro";
|
||||
variant = "high";
|
||||
}
|
||||
{
|
||||
model = "opencode/claude-opus-5";
|
||||
variant = "max";
|
||||
}
|
||||
];
|
||||
};
|
||||
librarian = {
|
||||
model = "minimax-coding-plan/MiniMax-M3";
|
||||
};
|
||||
explore = {
|
||||
model = "opencode/gpt-5-nano";
|
||||
fallback_models = [
|
||||
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||
];
|
||||
};
|
||||
"multimodal-looker" = {
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "low";
|
||||
fallback_models = [
|
||||
{ model = "opencode/gpt-5-nano"; }
|
||||
];
|
||||
};
|
||||
prometheus = {
|
||||
model = "opencode/claude-fable-5";
|
||||
variant = "high";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/kimi-k3";
|
||||
variant = "high";
|
||||
}
|
||||
];
|
||||
};
|
||||
metis = {
|
||||
model = "opencode/claude-opus-5";
|
||||
variant = "high";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/kimi-k3";
|
||||
variant = "low";
|
||||
}
|
||||
];
|
||||
};
|
||||
momus = {
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "xhigh";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/claude-opus-5";
|
||||
variant = "max";
|
||||
}
|
||||
{
|
||||
model = "opencode/gemini-3.1-pro";
|
||||
variant = "high";
|
||||
}
|
||||
];
|
||||
};
|
||||
atlas = {
|
||||
model = "opencode/claude-sonnet-4-6";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "medium";
|
||||
}
|
||||
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||
];
|
||||
};
|
||||
"sisyphus-junior" = {
|
||||
model = "opencode/claude-sonnet-4-6";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "medium";
|
||||
}
|
||||
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||
{ model = "opencode/big-pickle"; }
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
categories = {
|
||||
"visual-engineering" = {
|
||||
model = "opencode/gemini-3.1-pro";
|
||||
variant = "high";
|
||||
fallback_models = [
|
||||
{ model = "opencode/glm-5"; }
|
||||
{
|
||||
model = "opencode/claude-opus-5";
|
||||
variant = "max";
|
||||
}
|
||||
];
|
||||
};
|
||||
ultrabrain = {
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "xhigh";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/gemini-3.1-pro";
|
||||
variant = "high";
|
||||
}
|
||||
{
|
||||
model = "opencode/claude-opus-5";
|
||||
variant = "max";
|
||||
}
|
||||
];
|
||||
};
|
||||
deep = {
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "medium";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/claude-opus-5";
|
||||
variant = "max";
|
||||
}
|
||||
{
|
||||
model = "opencode/gemini-3.1-pro";
|
||||
variant = "high";
|
||||
}
|
||||
];
|
||||
};
|
||||
artistry = {
|
||||
model = "opencode/gemini-3.1-pro";
|
||||
variant = "high";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/claude-opus-5";
|
||||
variant = "max";
|
||||
}
|
||||
{
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "high";
|
||||
}
|
||||
];
|
||||
};
|
||||
quick = {
|
||||
model = "opencode/gpt-5.4-mini";
|
||||
fallback_models = [
|
||||
{ model = "opencode/gemini-3-flash"; }
|
||||
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||
{ model = "opencode/gpt-5-nano"; }
|
||||
];
|
||||
};
|
||||
"unspecified-low" = {
|
||||
model = "opencode/claude-sonnet-4-6";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "medium";
|
||||
}
|
||||
{ model = "opencode/gemini-3-flash"; }
|
||||
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||
];
|
||||
};
|
||||
"unspecified-high" = {
|
||||
model = "opencode/claude-sonnet-4-6";
|
||||
fallback_models = [
|
||||
{
|
||||
model = "opencode/gpt-5.6-sol";
|
||||
variant = "medium";
|
||||
}
|
||||
{ model = "opencode/gemini-3-flash"; }
|
||||
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||
];
|
||||
};
|
||||
writing = {
|
||||
model = "opencode/gemini-3-flash";
|
||||
fallback_models = [
|
||||
{ model = "opencode/claude-sonnet-4-6"; }
|
||||
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
let
|
||||
# nixpkgs ast-grep only ships binary `ast-grep`; omo's ast-grep skill probes
|
||||
# for `sg` (or ast-grep). Provide both via a symlink wrapper.
|
||||
astGrepWithSg = pkgs.runCommandLocal "ast-grep-with-sg" { } ''
|
||||
mkdir -p $out/bin
|
||||
ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/ast-grep
|
||||
ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/sg
|
||||
'';
|
||||
in
|
||||
{
|
||||
programs.opencode = {
|
||||
enable = true;
|
||||
|
||||
# Extras available to opencode-wrapped (via --suffix PATH on the wrapper):
|
||||
# pkgs.nodejs_22 — npx/npm for MCP servers (webpage-mcp) and omo's plugin loader
|
||||
# pkgs.ast-grep — `sg` CLI; omo's ast-grep skill requires it (omo doctor)
|
||||
# pkgs.bun — omo prefers bun; with bun on PATH, `omo doctor` skips node fallback
|
||||
# pkgs.gh — GitHub CLI; omo's GitHub automation features require it
|
||||
extraPackages = [
|
||||
pkgs.nodejs_22
|
||||
astGrepWithSg
|
||||
pkgs.bun
|
||||
pkgs.gh
|
||||
];
|
||||
|
||||
# ~/.config/opencode/opencode.json
|
||||
settings = {
|
||||
plugin = [ "oh-my-openagent@latest" ];
|
||||
mcp = {
|
||||
webpage = {
|
||||
type = "local";
|
||||
command = [
|
||||
"npx"
|
||||
"-y"
|
||||
"-p"
|
||||
"webpage-mcp@latest"
|
||||
"webpage-mcp-stdio"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ~/.config/opencode/tui.json
|
||||
# Mirrors workstation: oh-my-openagent also registered for the TUI.
|
||||
tui = {
|
||||
plugin = [ "oh-my-openagent@latest" ];
|
||||
};
|
||||
};
|
||||
|
||||
# ~/.config/opencode/oh-my-openagent.json — read by the plugin on startup.
|
||||
xdg.configFile."opencode/oh-my-openagent.json".text = builtins.toJSON ohMyOpenagentConfig;
|
||||
|
||||
# Same extras on the user's PATH too, so `omo doctor` and standalone invocations
|
||||
# of `sg`, `gh`, `bun`, `npm`, `npx` work in the user's shell — not only inside
|
||||
# the opencode-wrapped binary.
|
||||
home.packages = [
|
||||
pkgs.nodejs_22
|
||||
astGrepWithSg
|
||||
pkgs.bun
|
||||
pkgs.gh
|
||||
];
|
||||
|
||||
# Expose `opencode web` as a systemd user service. nginx on sapphira
|
||||
# proxies https://opencode.zeroq.su -> 127.0.0.1:4096.
|
||||
#
|
||||
# --hostname 0.0.0.0 binds the listener to every interface (matches the
|
||||
# "0.0.0.0" intent; nginx then reverse-proxies 127.0.0.1:4096 internally).
|
||||
# --cors https://opencode.zeroq.su lets the browser session reach the
|
||||
# server from that origin without CORS rejection.
|
||||
#
|
||||
# SECURITY: with no password, anyone reaching the upstream socket gets full
|
||||
# opencode. Bind 0.0.0.0 + listener == bridge == shell. The password is
|
||||
# supplied via sops-managed EnvironmentFile, declared in modules/users.nix
|
||||
# and decrypted to a path hardcoded here (home-manager modules cannot read
|
||||
# `config.sops.*` — sops-nix options are NixOS-only).
|
||||
programs.opencode.web = {
|
||||
enable = true;
|
||||
environmentFile = "${config.home.homeDirectory}/.config/opencode/server.env";
|
||||
extraArgs = [
|
||||
"--hostname"
|
||||
"0.0.0.0"
|
||||
"--cors"
|
||||
"https://opencode.zeroq.su"
|
||||
];
|
||||
};
|
||||
|
||||
# RAM constraints for the opencode-web user service.
|
||||
#
|
||||
# Sapphira has 5.6 GiB RAM with a ~1 GiB baseline (syncthing + immich + gitea
|
||||
# + x-ui + nextcloud php-fpm). When something else spikes (immich-ml jobs,
|
||||
# syncthing indexer, etc.) the system OOM killer activates and picks the
|
||||
# largest cgroup — opencode at ~260 MiB – 1.4 GiB peak was being chosen and
|
||||
# systemd then restarted it every few seconds (`RestartSec=5`), masking the
|
||||
# real cause as a "service crash". The 2026-10-04 incident was exactly this.
|
||||
#
|
||||
# Three knobs together make opencode stop being an OOM victim AND stop being
|
||||
# the source of an OOM:
|
||||
#
|
||||
# MemoryHigh soft pressure threshold: kernel reclaims aggressively
|
||||
# once the cgroup hits this. Process keeps running.
|
||||
# MemoryMax hard cap: cgroup-local OOM kills Node if exceeded. The
|
||||
# HOST survives — only this process dies, no restart storm.
|
||||
# OOMScoreAdjust negative bias for the system-wide OOM killer: opencode
|
||||
# is killed last, after syncthing/immich/etc.
|
||||
# OOMPolicy "continue" — systemd does NOT auto-restart on cgroup
|
||||
# OOM-kill. Without this, a spike triggers the same
|
||||
# restart-loop the host saw today.
|
||||
#
|
||||
# Sizes are derived from observed peak (1.4 GiB at 16:36, 1.1 GiB at 16:59).
|
||||
# MemoryHigh = 1G gives headroom for normal runs; MemoryMax = 2G caps
|
||||
# pathological growth. Tweak both together if a workload legitimately
|
||||
# needs more.
|
||||
#
|
||||
# Refs:
|
||||
# https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html
|
||||
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#OOMScoreAdjust=
|
||||
# Override the [Service] section emitted by `programs.opencode.web`.
|
||||
# Upstream writes its own [Service] keys (ExecStart, EnvironmentFile,
|
||||
# Restart, RestartSec); merging on the same `Service` attrset unions both
|
||||
# sides into the same systemd section, so cgroup limits land where systemd
|
||||
# actually reads them.
|
||||
#
|
||||
# NOTE: do NOT use `serviceConfig = { ... }` here — it is rendered as a
|
||||
# literal `[serviceConfig]` section header by home-manager, which systemd
|
||||
# silently ignores (verified on sapphira, journal: "Unknown section
|
||||
# 'serviceConfig'. Ignoring."). The previous version of this block was
|
||||
# exactly that, so the OOM/cgroup protection above never took effect.
|
||||
systemd.user.services.opencode-web.Service = {
|
||||
MemoryHigh = "1G";
|
||||
MemoryMax = "2G";
|
||||
OOMScoreAdjust = -900;
|
||||
OOMPolicy = "continue";
|
||||
};
|
||||
|
||||
# Workaround: home-manager activation updates the GC root `current-home`
|
||||
# only at the very end (line 358 of the generated activate script), AFTER all
|
||||
# `home.activation.*` dag entries have run. So we cannot read current-home
|
||||
# from a dag entry — it still points to the OLD generation at the time our
|
||||
# script executes. Instead, read `new-home`, which the activator writes
|
||||
# BEFORE any dag entry runs and which already points at the new generation.
|
||||
home.activation.relinkHomeManager = lib.hm.dag.entryAfter [] ''
|
||||
target="$HOME/.local/state/nix/profiles/home-manager-24-link"
|
||||
newGen="$(readlink -e "''${XDG_STATE_HOME:-$HOME/.local/state}/home-manager/gcroots/new-home" 2>/dev/null || true)"
|
||||
if [[ -n "$newGen" && "$(readlink -f "$target")" != "$newGen" ]]; then
|
||||
echo "home-manager: relinking $target -> $newGen"
|
||||
ln -sfn "$newGen" "$target"
|
||||
fi
|
||||
'';
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ let
|
||||
|
||||
"${config.home.homeDirectory}/Games/PrismLaunchers/${config.home.username}" =
|
||||
".local/share/PrismLauncher";
|
||||
"${xlib.dirs.lamet-drive}/Users/oqyude/Music" = "Music";
|
||||
"${xlib.dirs.lamet-drive}/Users/${xlib.device.username}/Music" = "Music";
|
||||
};
|
||||
mkLinks = lib.mapAttrs' (sourcePath: targetPath: {
|
||||
name = targetPath;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{
|
||||
imports = [
|
||||
./minimal.nix
|
||||
./modules/opencode.nix
|
||||
];
|
||||
home.file = xlib.helpers.mkSymlinks config {
|
||||
"${config.home.homeDirectory}/External/Music" = "Music";
|
||||
|
||||
+8
-8
@@ -218,7 +218,7 @@
|
||||
enable = true;
|
||||
settings = {
|
||||
user = {
|
||||
name = "oqyude";
|
||||
name = xlib.device.username;
|
||||
email = "oqyude@gmail.com";
|
||||
};
|
||||
pull = {
|
||||
@@ -250,31 +250,31 @@
|
||||
};
|
||||
sapphira = {
|
||||
HostName = "192.168.1.20";
|
||||
User = "oqyude";
|
||||
User = xlib.device.username;
|
||||
};
|
||||
sapphira-tailscale = {
|
||||
HostName = "100.64.0.0";
|
||||
User = "oqyude";
|
||||
User = xlib.device.username;
|
||||
};
|
||||
otreca-old = {
|
||||
HostName = "217.60.3.12";
|
||||
User = "oqyude";
|
||||
User = xlib.device.username;
|
||||
};
|
||||
otreca = {
|
||||
HostName = "109.248.161.5";
|
||||
User = "oqyude";
|
||||
User = xlib.device.username;
|
||||
};
|
||||
otreca-tailscale = {
|
||||
HostName = "100.64.1.0";
|
||||
User = "oqyude";
|
||||
User = xlib.device.username;
|
||||
};
|
||||
rydiwo = {
|
||||
HostName = "192.168.1.102";
|
||||
User = "oqyude";
|
||||
User = xlib.device.username;
|
||||
};
|
||||
epral = {
|
||||
HostName = "192.168.1.101";
|
||||
User = "oqyude";
|
||||
User = xlib.device.username;
|
||||
Port = 8022;
|
||||
};
|
||||
};
|
||||
|
||||
+14
-12
@@ -2,26 +2,28 @@
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
# Builds a NixOS system from a host record.
|
||||
#
|
||||
# `xlib` is the pure host value (lib/xlib.nix `mkXlib`) built in
|
||||
# configurations/default.nix. It is handed to every module as the `xlib`
|
||||
# argument, so modules read plain `xlib.*` data instead of `config.xlib.*`
|
||||
# and the host record stays the single source of truth.
|
||||
{
|
||||
deviceType,
|
||||
hostname ? null,
|
||||
xlib,
|
||||
modules ? [ ],
|
||||
system ? "x86_64-linux",
|
||||
...
|
||||
}:
|
||||
let
|
||||
lib = inputs.nixpkgs.lib;
|
||||
in
|
||||
lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = modules ++ [
|
||||
{
|
||||
xlib.device = {
|
||||
type = deviceType;
|
||||
}
|
||||
// lib.optionalAttrs (hostname != null) { inherit hostname; };
|
||||
}
|
||||
];
|
||||
inherit
|
||||
system
|
||||
modules
|
||||
;
|
||||
specialArgs = {
|
||||
inherit deviceType inputs;
|
||||
inherit inputs;
|
||||
inherit xlib;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Pure host library: no module system involved.
|
||||
#
|
||||
# Aggregates the four concerns a host record is built from:
|
||||
# device.nix identity + capability flags from the device type
|
||||
# dirs.nix well-known paths, derived from username
|
||||
# helpers.nix pure helper functions shared by modules
|
||||
#
|
||||
# `mkXlib` is called in flake-level code (configurations/default.nix) and
|
||||
# handed to every module as the `xlib` argument via lib/mkSystem.nix, so
|
||||
# modules read plain `xlib.*` values instead of `config.xlib.*` and nothing in
|
||||
# xlib can be overridden per host — the host record is the only place to
|
||||
# change it.
|
||||
{
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (import ./device.nix { inherit lib; })
|
||||
devices
|
||||
mkDevice
|
||||
;
|
||||
|
||||
# dirs.nix is itself a function of `username`, not an attrset.
|
||||
mkDirs = import ./dirs.nix;
|
||||
|
||||
helpers = (import ./helpers.nix { inherit lib; });
|
||||
in
|
||||
{
|
||||
inherit
|
||||
devices
|
||||
helpers
|
||||
mkDevice
|
||||
mkDirs
|
||||
;
|
||||
|
||||
# Full host record: identity + capability flags + well-known paths +
|
||||
# shared helpers.
|
||||
mkXlib =
|
||||
{
|
||||
hostname,
|
||||
type,
|
||||
username ? "oqyude",
|
||||
uid ? 1000,
|
||||
gid ? 1000,
|
||||
}:
|
||||
let
|
||||
device = mkDevice {
|
||||
inherit
|
||||
hostname
|
||||
type
|
||||
username
|
||||
uid
|
||||
gid
|
||||
;
|
||||
};
|
||||
in
|
||||
{
|
||||
device = {
|
||||
inherit
|
||||
hostname
|
||||
type
|
||||
username
|
||||
uid
|
||||
gid
|
||||
;
|
||||
};
|
||||
isDesktop = device.isDesktop;
|
||||
isHeadless = device.isHeadless;
|
||||
dirs = mkDirs username;
|
||||
# Bind the host's ids into the mount helpers, so ntfs3/exfat options
|
||||
# carry the same uid/gid the primary user actually has.
|
||||
helpers = import ./helpers.nix {
|
||||
inherit lib;
|
||||
uid = device.uid;
|
||||
gid = device.gid;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
# Supported device types and the identity record built from one.
|
||||
#
|
||||
# Single source of truth for host identity: hostname, type, username and the
|
||||
# capability flags derived from the type. Replaces the old `lib.types.enum`
|
||||
# in modules/options.nix and the hand-written type lists in modules/default.nix
|
||||
# and home/home.nix.
|
||||
let
|
||||
devices = {
|
||||
minimal = {
|
||||
desktop = false;
|
||||
headless = false;
|
||||
};
|
||||
primary = {
|
||||
desktop = true;
|
||||
headless = false;
|
||||
};
|
||||
secondary = {
|
||||
desktop = true;
|
||||
headless = false;
|
||||
};
|
||||
server = {
|
||||
desktop = false;
|
||||
headless = true;
|
||||
};
|
||||
vds = {
|
||||
desktop = false;
|
||||
headless = true;
|
||||
};
|
||||
wsl = {
|
||||
desktop = false;
|
||||
headless = true;
|
||||
};
|
||||
termux = {
|
||||
desktop = false;
|
||||
headless = true;
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit devices;
|
||||
|
||||
# Unknown device type fails here, at flake level, with the valid list.
|
||||
mkDevice =
|
||||
{
|
||||
hostname,
|
||||
type,
|
||||
username ? "oqyude",
|
||||
# The primary user is pinned to 1000 rather than left to NixOS'
|
||||
# nextfree logic: the mount helpers below write uid=/gid= into
|
||||
# ntfs3/exfat options, and an NTFS/exFAT volume mounted with a
|
||||
# different id shows every file as owned by `nobody`.
|
||||
uid ? 1000,
|
||||
gid ? 1000,
|
||||
}:
|
||||
let
|
||||
capabilities =
|
||||
devices.${type}
|
||||
or (throw "xlib: unknown device type '${type}', expected one of ${lib.concatStringsSep ", " (builtins.attrNames devices)}");
|
||||
in
|
||||
{
|
||||
inherit
|
||||
hostname
|
||||
type
|
||||
username
|
||||
uid
|
||||
gid
|
||||
;
|
||||
isDesktop = capabilities.desktop;
|
||||
isHeadless = capabilities.headless;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Well-known paths. Everything derives from `username`, which is why the
|
||||
# whole set can be computed outside the module system.
|
||||
username:
|
||||
let
|
||||
user-home = "/home/${username}";
|
||||
wsl-home = "/mnt/c/Users/${username}";
|
||||
server-home = "${user-home}/External";
|
||||
services-mnt-folder = "/mnt/services";
|
||||
in
|
||||
{
|
||||
inherit
|
||||
user-home
|
||||
wsl-home
|
||||
server-home
|
||||
services-mnt-folder
|
||||
;
|
||||
|
||||
user-storage = "${user-home}/Storage";
|
||||
wsl-storage = "${wsl-home}/Storage";
|
||||
server-credentials = "${server-home}/Credentials/server";
|
||||
storage = "${server-home}/Storage";
|
||||
calibre-library = "${server-home}/Books-Library";
|
||||
services-folder = "${server-home}/Services";
|
||||
services-nodes-folder = "${services-mnt-folder}/nodes";
|
||||
postgresql-folder = "${services-mnt-folder}/postgresql";
|
||||
music-library = "${user-home}/Music";
|
||||
|
||||
archive-drive = "/mnt/archive";
|
||||
lamet-drive = "/mnt/lamet";
|
||||
mobile-drive = "/mnt/mobile";
|
||||
therima-drive = "/mnt/therima";
|
||||
vetymae-drive = "/mnt/vetymae";
|
||||
soptur-drive = "/mnt/soptur";
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
{
|
||||
lib,
|
||||
# The primary user's ids, bound from xlib.device by mkXlib. ntfs3/exfat
|
||||
# volumes carry POSIX ids, so a mount using anything other than the real
|
||||
# uid/gid shows every file as owned by `nobody`.
|
||||
uid,
|
||||
gid,
|
||||
...
|
||||
}:
|
||||
# Shared pure helper functions for module definitions.
|
||||
# Injected into every module via `xlib.helpers` (see options.nix).
|
||||
# Pure helper functions for module definitions.
|
||||
# Injected into every module via `xlib.helpers` (see default.nix).
|
||||
#
|
||||
# Defined in a `let` because they reference each other (mkTmpDirs uses
|
||||
# mkTmpfile, mkServiceStorage uses mkTmpDirs + mkSystemdBind).
|
||||
let
|
||||
# tmpfiles rule: "type dir mode user group -"
|
||||
mkTmpfile =
|
||||
@@ -102,8 +110,8 @@ let
|
||||
fsType = "ntfs3";
|
||||
options = [
|
||||
"defaults"
|
||||
"uid=1000"
|
||||
"gid=1000"
|
||||
"uid=${toString uid}"
|
||||
"gid=${toString gid}"
|
||||
"fmask=${mask}"
|
||||
"dmask=${mask}"
|
||||
"nofail"
|
||||
@@ -125,8 +133,8 @@ let
|
||||
fsType = "exfat";
|
||||
options = [
|
||||
"nofail"
|
||||
"uid=1000"
|
||||
"gid=1000"
|
||||
"uid=${toString uid}"
|
||||
"gid=${toString gid}"
|
||||
];
|
||||
};
|
||||
};
|
||||
@@ -7,7 +7,11 @@
|
||||
}:
|
||||
let
|
||||
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/3x-ui";
|
||||
certDomain = xlib.services."3x-ui".certDomain or null;
|
||||
# Domain whose Let's Encrypt cert (at /var/lib/acme/<domain>/) 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
|
||||
[ ]
|
||||
@@ -28,94 +32,99 @@ let
|
||||
];
|
||||
# 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 xlib.services."3x-ui".reality443Forwarding "127.0.0.1:15380:443/tcp";
|
||||
realityPorts = lib.optional config.host."3x-ui".reality443Forwarding "127.0.0.1:15380:443/tcp";
|
||||
in
|
||||
{
|
||||
virtualisation = {
|
||||
podman = {
|
||||
enable = true;
|
||||
autoPrune = {
|
||||
# `host."3x-ui"` options are declared in modules/options.nix: they are set
|
||||
# by modules/server and modules/vds, so this module cannot be the only place
|
||||
# that knows they exist.
|
||||
config = {
|
||||
virtualisation = {
|
||||
podman = {
|
||||
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";
|
||||
autoPrune = {
|
||||
enable = true;
|
||||
flags = [ "--all" ];
|
||||
};
|
||||
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;
|
||||
dockerCompat = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
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;
|
||||
};
|
||||
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 ];
|
||||
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 ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
# The image is built here rather than pulled: zaakirio/kokoro-ru is a
|
||||
# Hugging Face repo, not a published OCI image, and its Russian G2P has to be
|
||||
# driven through the repo's own ru_g2p.py.
|
||||
#
|
||||
# The build context goes through the store so the image is pinned to the
|
||||
# config revision: edit a file, `nixos-rebuild`, and the unit below rebuilds
|
||||
# and restarts. Reading the context off a checkout at runtime would leave the
|
||||
# running container untraceable back to any config.
|
||||
#
|
||||
# runCommand rather than linkFarm: linkFarm entries are symlinks into other
|
||||
# store paths, and `podman build` only mounts the context root, so every COPY
|
||||
# fails with "copier: get: lstat ...: no such file or directory". Copying the
|
||||
# bytes in leaves the context with no symlinks that escape its root.
|
||||
source = pkgs.runCommand "kokoro-tts-source" { } ''
|
||||
mkdir -p "$out"
|
||||
cp -L ${./kokoro-tts/Dockerfile} "$out/Dockerfile"
|
||||
cp -L ${./kokoro-tts/app.py} "$out/app.py"
|
||||
cp -L ${./kokoro-tts/fetch_assets.py} "$out/fetch_assets.py"
|
||||
cp -L ${./kokoro-tts/requirements.txt} "$out/requirements.txt"
|
||||
'';
|
||||
|
||||
image = "localhost/kokoro-tts:latest";
|
||||
|
||||
# Unchanged from the silero module, so whatever already points at
|
||||
# http://127.0.0.1:9898/v1 keeps working without edits.
|
||||
hostPort = 9898;
|
||||
containerPort = 8000;
|
||||
in
|
||||
{
|
||||
config = {
|
||||
virtualisation = {
|
||||
podman = {
|
||||
enable = true;
|
||||
|
||||
autoPrune = {
|
||||
enable = true;
|
||||
flags = [ "--all" ];
|
||||
};
|
||||
|
||||
dockerCompat = true;
|
||||
};
|
||||
|
||||
oci-containers = {
|
||||
backend = "podman";
|
||||
|
||||
containers.kokoro-tts = {
|
||||
image = image;
|
||||
|
||||
ports = [
|
||||
"127.0.0.1:${toString hostPort}:${toString containerPort}"
|
||||
];
|
||||
|
||||
environment = {
|
||||
# Inference is CPU-bound and already threaded inside torch. Measured
|
||||
# on a 24-logical-core host: median end-to-end latency for a 5.6 s
|
||||
# utterance was 1.203 s at 4 threads, 0.979 s at 12, 0.980 s at 16
|
||||
# and 1.87 s at 24, so the useful ceiling is the physical core count
|
||||
# and oversubscribing it roughly doubles the wait. These three must
|
||||
# stay equal to the Dockerfile ENV and the app.py default: whichever
|
||||
# of the three is set wins over the others.
|
||||
KOKORO_THREADS = "12";
|
||||
OMP_NUM_THREADS = "12";
|
||||
MKL_NUM_THREADS = "12";
|
||||
TZ = "Europe/Moscow";
|
||||
};
|
||||
|
||||
# No volumes: the checkpoints, the acute-aware espeak data and
|
||||
# ruaccent's ONNX models are all baked into the image, so the
|
||||
# container needs neither a host directory nor the network to start.
|
||||
log-driver = "journald";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
systemd = {
|
||||
services = {
|
||||
# Runs before the container. BuildKit caches the expensive layers, so
|
||||
# on every boot after the first this is a no-op that still verifies the
|
||||
# image exists.
|
||||
"podman-build-kokoro-tts" = {
|
||||
path = [ pkgs.podman ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# First build pulls torch wheels plus ~700 MB of weights.
|
||||
TimeoutSec = 3600;
|
||||
};
|
||||
|
||||
script = ''
|
||||
podman build -t ${image} ${source}
|
||||
'';
|
||||
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
};
|
||||
|
||||
"podman-kokoro-tts" = {
|
||||
# The image does not exist until the build above ran, and a `latest`
|
||||
# tag must be re-pulled on rebuild, so ordering has to be explicit.
|
||||
after = [ "podman-build-kokoro-tts.service" ];
|
||||
requires = [ "podman-build-kokoro-tts.service" ];
|
||||
serviceConfig.Restart = lib.mkOverride 90 "always";
|
||||
# Auto-start disabled: start manually with `systemctl start podman-kokoro-tts`.
|
||||
wantedBy = [ ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Fully qualified on purpose: NixOS ships a podman registries.conf without
|
||||
# unqualified-search-registries, so a bare "python:3.12-slim-bookworm" fails to
|
||||
# resolve before the build even starts.
|
||||
FROM docker.io/library/python:3.12-slim-bookworm
|
||||
|
||||
# Pinned, not "main": a rebuild that only touched the Nix module must not
|
||||
# silently pick up different weights. Bump these deliberately.
|
||||
ARG KOKORO_RU_REPO=zaakirio/kokoro-ru
|
||||
ARG KOKORO_RU_REVISION=d649c57b239b18c4c384378127cbf01dba039bc1
|
||||
# Trim to "sveta" to halve the image: masha shares her checkpoint and dima is
|
||||
# a second 327 MB one.
|
||||
ARG KOKORO_RU_VOICES=sveta,masha,dima
|
||||
|
||||
# Thread counts, not a guess: see app.py THREADS. 12 was the measured plateau on
|
||||
# a 24-logical-core host, and 24 was ~2x worse. Must stay equal to the Nix
|
||||
# module's environment.environment, which wins over this ENV.
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
HF_HUB_DISABLE_TELEMETRY=1 \
|
||||
HF_HUB_DISABLE_SYMLINKS_WARNING=1 \
|
||||
KOKORO_RU_REPO=${KOKORO_RU_REPO} \
|
||||
KOKORO_RU_REVISION=${KOKORO_RU_REVISION} \
|
||||
KOKORO_RU_VOICES=${KOKORO_RU_VOICES} \
|
||||
KOKORO_MODEL_DIR=/app/kokoro-ru \
|
||||
KOKORO_THREADS=12 \
|
||||
OMP_NUM_THREADS=12 \
|
||||
MKL_NUM_THREADS=12 \
|
||||
TZ=Europe/Moscow
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# libgomp1 is torch's OpenMP runtime. espeak-ng comes from the espeakng-loader
|
||||
# wheel rather than the distro package because the model needs its own
|
||||
# recompiled ru_dict, and libsndfile is absent because WAV/PCM are written with
|
||||
# stdlib `wave` while every other format goes through imageio-ffmpeg.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libgomp1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# CPU-only torch from its own index: the default PyPI wheel drags in ~2.5 GB of
|
||||
# CUDA libraries for a machine that has no GPU.
|
||||
RUN pip install --index-url https://download.pytorch.org/whl/cpu torch
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# fetch_assets.py is copied on its own and app.py only after the snapshot, never
|
||||
# as one COPY. A single COPY would tie the 639 MB download to the application
|
||||
# source: any edit to app.py would invalidate this layer and refetch every
|
||||
# checkpoint as hundreds of anonymous, rate-limited requests.
|
||||
COPY fetch_assets.py ./
|
||||
|
||||
# Bakes the checkpoints, the acute-aware espeak data and ruaccent's ONNX models
|
||||
# into the layer, which is what lets the container start with no network and no
|
||||
# writable volume.
|
||||
RUN python fetch_assets.py
|
||||
|
||||
COPY app.py ./
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4)"]
|
||||
|
||||
# No workers: the model is a shared in-process singleton, so a second worker
|
||||
# would only mean a second copy of ~2 GB of weights.
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
@@ -0,0 +1,585 @@
|
||||
"""OpenAI-compatible TTS API backed by zaakirio/kokoro-ru.
|
||||
|
||||
The model itself is language-blind: phoneme ids in, 24 kHz audio out. All the
|
||||
Russian lives in the G2P front-end, and the one that matters is kokoro-ru's own
|
||||
`ru_g2p.py` — RUAccent resolves lexical stress, ё and homographs, then an
|
||||
acute-aware espeak-ng phonemizer turns that into IPA. Stock misaki Russian is
|
||||
espeak-only and gets stress wrong often enough that the model reads as
|
||||
non-native (measured 27% vs 22% round-trip WER, per the model card).
|
||||
|
||||
So: text -> RuG2P.phonemize -> KModel(ipa, voicepack[len(ipa) - 1]) -> waveform.
|
||||
|
||||
Endpoints
|
||||
POST /v1/audio/speech OpenAI text-to-speech
|
||||
POST /v1/audio/speech/stream same, but mp3/opus emitted while synthesising
|
||||
GET /v1/models OpenAI model list
|
||||
GET /v1/voices voice inventory (extension, not part of OpenAI)
|
||||
GET /healthz readiness, 503 until the model is loaded
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import wave
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Iterator, Literal
|
||||
|
||||
import numpy as np
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
if TYPE_CHECKING: # torch is imported lazily so /healthz answers during boot
|
||||
import torch
|
||||
|
||||
MODEL_ID = "kokoro-ru"
|
||||
SAMPLE_RATE = 24000
|
||||
MODEL_DIR = Path(os.environ.get("KOKORO_MODEL_DIR", "/app/kokoro-ru"))
|
||||
DEFAULT_VOICE = os.environ.get("KOKORO_DEFAULT_VOICE", "sveta")
|
||||
# Measured on the host this was tuned for (Ryzen AI 9 HX 370, 24 logical cores):
|
||||
# median end-to-end latency for a 5.6 s utterance was 1.203 s @ 4 threads,
|
||||
# 1.066 s @ 8, 0.979 s @ 12, 0.980 s @ 16, then 1.87 s @ 24. The gain stops at
|
||||
# the physical core count and SMT oversubscription costs ~2x, so cap instead of
|
||||
# trusting os.cpu_count(), which reports logical CPUs. Override on other hosts.
|
||||
THREADS = int(os.environ.get("KOKORO_THREADS", min(12, os.cpu_count() or 4)))
|
||||
# 2026-07-29, when the kokoro-ru revision we pin was published. Clients that
|
||||
# cache on this treat any change as a new model, so it must stay stable.
|
||||
MODEL_CREATED = 1785353253
|
||||
|
||||
# voice -> (checkpoint stem, gender). The checkpoint carries the timbre and the
|
||||
# voicepack the identity, which is why sveta and masha share one file.
|
||||
VOICE_SPECS: dict[str, tuple[str, str]] = {
|
||||
"sveta": ("kokoro-ru-v2-base", "female"),
|
||||
"masha": ("kokoro-ru-v2-base", "female"),
|
||||
"dima": ("kokoro-ru-v2-dima", "male"),
|
||||
}
|
||||
|
||||
# Clients that ship the OpenAI voice list (alloy, nova, echo, ...) send those
|
||||
# names unless the user overrides them, so map them onto the three we have.
|
||||
VOICE_ALIASES: dict[str, str] = {
|
||||
"alloy": "sveta",
|
||||
"ash": "sveta",
|
||||
"ballad": "sveta",
|
||||
"verse": "sveta",
|
||||
"marin": "sveta",
|
||||
"coral": "masha",
|
||||
"sage": "masha",
|
||||
"shimmer": "masha",
|
||||
"cedar": "masha",
|
||||
"echo": "dima",
|
||||
"fable": "dima",
|
||||
"onyx": "dima",
|
||||
}
|
||||
|
||||
CONTENT_TYPES = {
|
||||
"wav": "audio/wav",
|
||||
"mp3": "audio/mpeg",
|
||||
"opus": "audio/ogg",
|
||||
"aac": "audio/aac",
|
||||
"flac": "audio/flac",
|
||||
"pcm": "audio/pcm",
|
||||
}
|
||||
|
||||
# Everything except wav and pcm goes through ffmpeg; those two are byte-exact
|
||||
# from the stdlib and need no encoder at all.
|
||||
FFMPEG_ARGS = {
|
||||
"mp3": ["-c:a", "libmp3lame", "-q:a", "2"],
|
||||
"opus": ["-c:a", "libopus", "-b:a", "64k"],
|
||||
"aac": ["-c:a", "aac", "-b:a", "128k"],
|
||||
"flac": ["-c:a", "flac"],
|
||||
}
|
||||
FFMPEG_CONTAINERS = {"mp3": "mp3", "opus": "ogg", "aac": "adts", "flac": "flac"}
|
||||
|
||||
# Kokoro's Albert context is 510 tokens and KModel.forward asserts
|
||||
# len(ids) + 2 <= 510, so 508 phonemes is the hard ceiling per forward pass.
|
||||
MAX_PHONEMES = 508
|
||||
# Roughly 300 characters of Russian lands near 400 phonemes, comfortably under
|
||||
# the ceiling, and keeps a chunk short enough that a bad sentence is a short
|
||||
# chunk.
|
||||
CHUNK_CHARS = 300
|
||||
# Silence inserted between chunks. Without it the concatenation clicks at every
|
||||
# boundary because each forward pass starts and ends on a zero crossing.
|
||||
CHUNK_GAP_S = 0.08
|
||||
|
||||
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("kokoro-ru")
|
||||
|
||||
|
||||
def split_text(text: str, budget: int = CHUNK_CHARS) -> list[str]:
|
||||
"""Split into sentence-bounded chunks, hard-cutting only as a last resort.
|
||||
|
||||
Phonemizing per sentence rather than per paragraph keeps RUAccent's stress
|
||||
decisions local and gives the model a reset point at every full stop.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for sentence in _SENTENCE_SPLIT.split(text.strip()):
|
||||
sentence = sentence.strip()
|
||||
while len(sentence) > budget:
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = ""
|
||||
chunks.append(sentence[:budget])
|
||||
sentence = sentence[budget:].strip()
|
||||
if not sentence:
|
||||
continue
|
||||
if len(current) + len(sentence) + 1 > budget:
|
||||
# Guarded: when the first sentence fills the budget exactly, or the
|
||||
# previous one was hard-cut down to nothing, `current` is empty and
|
||||
# a bare append would queue a zero-length chunk.
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = sentence
|
||||
else:
|
||||
current = f"{current} {sentence}".strip()
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks
|
||||
|
||||
|
||||
def split_phonemes(ps: str, limit: int = MAX_PHONEMES) -> list[str]:
|
||||
"""Cut an over-long phoneme string on word boundaries."""
|
||||
if len(ps) <= limit:
|
||||
return [ps]
|
||||
parts: list[str] = []
|
||||
rest = ps
|
||||
while len(rest) > limit:
|
||||
cut = rest.rfind(" ", 0, limit)
|
||||
if cut <= 0:
|
||||
cut = limit
|
||||
parts.append(rest[:cut].strip())
|
||||
rest = rest[cut:].strip()
|
||||
if rest:
|
||||
parts.append(rest)
|
||||
return [part for part in parts if part]
|
||||
|
||||
|
||||
class KokoroRu:
|
||||
"""Loaded model plus the G2P front-end, behind a single inference lock."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._torch: torch | None = None
|
||||
self._g2p = None
|
||||
self._models: dict[str, torch.nn.Module] = {}
|
||||
self._packs: dict[str, torch.Tensor] = {}
|
||||
# The Albert encoder and the iSTFTNet decoder keep per-call scratch
|
||||
# buffers; concurrent forwards on one model interleave into them. The
|
||||
# model is fast enough on CPU that serialising is not the bottleneck.
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def load(self) -> None:
|
||||
import torch
|
||||
from kokoro import KModel
|
||||
|
||||
torch.set_num_threads(THREADS)
|
||||
self._torch = torch
|
||||
|
||||
# RuG2P is imported from the baked snapshot, not installed, and it
|
||||
# resolves espeak-data/ plus kokoro-config.json next to itself.
|
||||
sys.path.insert(0, str(MODEL_DIR))
|
||||
from ru_g2p import RuG2P
|
||||
|
||||
self._g2p = RuG2P(
|
||||
espeak_data=MODEL_DIR / "espeak-data",
|
||||
vocab_path=MODEL_DIR / "kokoro-config.json",
|
||||
)
|
||||
|
||||
for stem in sorted({stem for stem, _ in VOICE_SPECS.values()}):
|
||||
checkpoint = MODEL_DIR / f"{stem}.pth"
|
||||
if not checkpoint.exists():
|
||||
log.warning("checkpoint %s missing, voices using it stay unavailable", checkpoint)
|
||||
continue
|
||||
# repo_id is only used to build the default model filename; passing
|
||||
# both config and model keeps it from touching the HF cache at all.
|
||||
self._models[stem] = KModel(
|
||||
repo_id=str(MODEL_DIR),
|
||||
config=str(MODEL_DIR / "config.json"),
|
||||
model=str(checkpoint),
|
||||
).eval()
|
||||
log.info("loaded checkpoint %s", checkpoint.name)
|
||||
|
||||
for name in VOICE_SPECS:
|
||||
pack = MODEL_DIR / "voices" / f"{name}.pt"
|
||||
if pack.exists():
|
||||
self._packs[name] = torch.load(str(pack), map_location="cpu", weights_only=True)
|
||||
|
||||
if not self.available_voices():
|
||||
raise RuntimeError(f"no usable voices under {MODEL_DIR}")
|
||||
|
||||
def available_voices(self) -> list[str]:
|
||||
return [
|
||||
name
|
||||
for name in VOICE_SPECS
|
||||
if name in self._packs and VOICE_SPECS[name][0] in self._models
|
||||
]
|
||||
|
||||
def phonemes(self, text: str):
|
||||
for chunk in split_text(text):
|
||||
ps, _oov = self._g2p.phonemize(chunk)
|
||||
ps = ps.strip()
|
||||
if ps:
|
||||
yield from split_phonemes(ps)
|
||||
|
||||
def iter_audio_chunks(self, text: str, voice: str, speed: float):
|
||||
"""Yields float32 audio per phoneme chunk, silence gaps interleaved.
|
||||
|
||||
The engine lock is held for the whole iteration, so a caller that stops
|
||||
consuming early releases synthesis for everyone else.
|
||||
"""
|
||||
torch = self._torch
|
||||
assert torch is not None, "synthesize() before load()"
|
||||
stem, _gender = VOICE_SPECS[voice]
|
||||
model = self._models[stem]
|
||||
pack = self._packs[voice]
|
||||
|
||||
gap = np.zeros(int(CHUNK_GAP_S * SAMPLE_RATE), dtype=np.float32)
|
||||
with self._lock:
|
||||
for index, ps in enumerate(self.phonemes(text)):
|
||||
# The style vector is picked by phoneme-string length, which is
|
||||
# why the model sounds deterministic for identical text.
|
||||
style = pack[len(ps) - 1]
|
||||
# The packs ship as [510, 256]; KModel wants a batch of one.
|
||||
if style.dim() == 1:
|
||||
style = style.unsqueeze(0)
|
||||
if index:
|
||||
yield gap
|
||||
yield np.asarray(
|
||||
model(ps, style, speed, return_output=True).audio,
|
||||
dtype=np.float32,
|
||||
).reshape(-1)
|
||||
|
||||
def synthesize(self, text: str, voice: str, speed: float) -> np.ndarray:
|
||||
chunks = list(self.iter_audio_chunks(text, voice, speed))
|
||||
if not chunks:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
return np.concatenate(chunks)
|
||||
|
||||
|
||||
def encode(audio: np.ndarray, fmt: str) -> bytes:
|
||||
clipped = np.clip(audio, -1.0, 1.0)
|
||||
if fmt == "pcm":
|
||||
# OpenAI's pcm is raw signed 16-bit little-endian mono at 24 kHz.
|
||||
return (clipped * 32767.0).astype("<i2").tobytes()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with wave.open(buffer, "wb") as out:
|
||||
out.setnchannels(1)
|
||||
out.setsampwidth(2)
|
||||
out.setframerate(SAMPLE_RATE)
|
||||
out.writeframes((clipped * 32767.0).astype("<i2").tobytes())
|
||||
wav = buffer.getvalue()
|
||||
|
||||
if fmt == "wav":
|
||||
return wav
|
||||
|
||||
import imageio_ffmpeg
|
||||
|
||||
command = [
|
||||
imageio_ffmpeg.get_ffmpeg_exe(),
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-ar",
|
||||
str(SAMPLE_RATE),
|
||||
"-ac",
|
||||
"1",
|
||||
*FFMPEG_ARGS[fmt],
|
||||
"-f",
|
||||
FFMPEG_CONTAINERS[fmt],
|
||||
"pipe:1",
|
||||
]
|
||||
done = subprocess.run(command, input=wav, capture_output=True, check=False)
|
||||
if done.returncode != 0:
|
||||
raise RuntimeError(done.stderr.decode("utf-8", "replace").strip()[-400:])
|
||||
return done.stdout
|
||||
|
||||
|
||||
class StreamEncoder:
|
||||
"""One long-lived ffmpeg per request: raw PCM in, encoded bytes out.
|
||||
|
||||
A single process is what keeps the container valid. Handing it the audio in
|
||||
pieces as they are synthesised avoids any byte-level concatenation, whereas
|
||||
encoding the pieces separately and joining the results would emit chained
|
||||
Ogg for opus, which plenty of players reject.
|
||||
"""
|
||||
|
||||
def __init__(self, fmt: str) -> None:
|
||||
import imageio_ffmpeg
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
[
|
||||
imageio_ffmpeg.get_ffmpeg_exe(),
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"s16le",
|
||||
"-ar",
|
||||
str(SAMPLE_RATE),
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
*FFMPEG_ARGS[fmt],
|
||||
"-f",
|
||||
FFMPEG_CONTAINERS[fmt],
|
||||
"pipe:1",
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self._blocks: queue.Queue[bytes | None] = queue.Queue()
|
||||
self._reader = threading.Thread(target=self._pump, daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
def _pump(self) -> None:
|
||||
assert self._proc.stdout is not None
|
||||
while True:
|
||||
block = self._proc.stdout.read(8192)
|
||||
if not block:
|
||||
break
|
||||
self._blocks.put(block)
|
||||
self._blocks.put(None)
|
||||
|
||||
def push(self, audio: np.ndarray) -> None:
|
||||
assert self._proc.stdin is not None
|
||||
clipped = np.clip(audio, -1.0, 1.0)
|
||||
self._proc.stdin.write((clipped * 32767.0).astype("<i2").tobytes())
|
||||
self._proc.stdin.flush()
|
||||
|
||||
def drain(self) -> Iterator[bytes]:
|
||||
"""Yields whatever ffmpeg has already emitted, without waiting for more."""
|
||||
while True:
|
||||
try:
|
||||
block = self._blocks.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
if block is None:
|
||||
return
|
||||
yield block
|
||||
|
||||
def finish(self) -> Iterator[bytes]:
|
||||
assert self._proc.stdin is not None
|
||||
self._proc.stdin.close()
|
||||
self._reader.join(timeout=120)
|
||||
code = self._proc.wait(timeout=30)
|
||||
error = self._proc.stderr.read().decode("utf-8", "replace").strip()[-400:]
|
||||
if code != 0:
|
||||
raise RuntimeError(error or f"ffmpeg exited with {code}")
|
||||
yield from self.drain()
|
||||
|
||||
def abort(self) -> None:
|
||||
if self._proc.poll() is None:
|
||||
self._proc.kill()
|
||||
|
||||
|
||||
engine = KokoroRu()
|
||||
state: dict[str, str | None] = {"status": "loading", "error": None}
|
||||
|
||||
|
||||
def boot() -> None:
|
||||
try:
|
||||
engine.load()
|
||||
state["status"] = "ready"
|
||||
log.info("ready: voices=%s", ", ".join(engine.available_voices()))
|
||||
except Exception as exc:
|
||||
state["status"] = "error"
|
||||
state["error"] = f"{type(exc).__name__}: {exc}"
|
||||
log.exception("model failed to load")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
# Off the event loop: loading pulls ~700 MB of weights and runs three ONNX
|
||||
# sessions, and /healthz has to stay answerable while it happens.
|
||||
threading.Thread(target=boot, name="kokoro-load", daemon=True).start()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="kokoro-ru OpenAI TTS", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
Format = Literal["mp3", "opus", "aac", "flac", "wav", "pcm"]
|
||||
|
||||
|
||||
class SpeechRequest(BaseModel):
|
||||
# `protected_namespaces` silences pydantic's warning about the `model_`
|
||||
# prefix; `extra="ignore"` absorbs the fields newer OpenAI clients add
|
||||
# (instructions, the legacy `format` alias) without failing the request.
|
||||
model_config = ConfigDict(extra="ignore", protected_namespaces=())
|
||||
|
||||
input: str = Field(min_length=1)
|
||||
model: str = MODEL_ID
|
||||
voice: str | None = None
|
||||
response_format: Format = "wav"
|
||||
speed: float | None = Field(default=None, ge=0.25, le=4.0)
|
||||
|
||||
|
||||
class StreamSpeechRequest(SpeechRequest):
|
||||
# Streaming needs a container that tolerates unknown length up front, so wav
|
||||
# (whose header declares the final sizes) and the raw formats are out. mp3
|
||||
# and opus emit bytes as they go, which is the whole point of the endpoint.
|
||||
response_format: Literal["mp3", "opus"] = "mp3"
|
||||
|
||||
|
||||
def fail(status: int, message: str, param: str | None = None, code: str | None = None) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": "invalid_request_error" if status < 500 else "server_error",
|
||||
"param": param,
|
||||
"code": code,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def resolve_voice(requested: str | None) -> str | None:
|
||||
name = (requested or DEFAULT_VOICE).strip().lower()
|
||||
name = VOICE_ALIASES.get(name, name)
|
||||
return name if name in engine.available_voices() else None
|
||||
|
||||
|
||||
# response_model=None: the handler returns a Response subclass directly, and
|
||||
# FastAPI would otherwise try to build a Pydantic model out of the union.
|
||||
@app.post("/v1/audio/speech", response_model=None)
|
||||
def create_speech(request: SpeechRequest) -> Response | JSONResponse:
|
||||
if state["status"] != "ready":
|
||||
return fail(503, f"model is not ready: {state['status']}", code="model_not_ready")
|
||||
|
||||
voice = resolve_voice(request.voice)
|
||||
if voice is None:
|
||||
available = ", ".join(engine.available_voices())
|
||||
return fail(
|
||||
400,
|
||||
f"unknown voice {request.voice!r}; available: {available}",
|
||||
param="voice",
|
||||
code="unknown_voice",
|
||||
)
|
||||
|
||||
try:
|
||||
audio = engine.synthesize(request.input, voice, request.speed or 1.0)
|
||||
except Exception as exc:
|
||||
log.exception("synthesis failed")
|
||||
return fail(500, f"synthesis failed: {exc}", code="synthesis_failed")
|
||||
|
||||
if audio.size == 0:
|
||||
return fail(
|
||||
400,
|
||||
"input contains no speakable text for the Russian G2P",
|
||||
param="input",
|
||||
code="no_phonemes",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = encode(audio, request.response_format)
|
||||
except Exception as exc:
|
||||
log.exception("encoding to %s failed", request.response_format)
|
||||
return fail(500, f"encoding to {request.response_format} failed: {exc}", code="encoding_failed")
|
||||
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type=CONTENT_TYPES[request.response_format],
|
||||
headers={"model-id": MODEL_ID, "voice-id": voice},
|
||||
)
|
||||
|
||||
|
||||
# response_model=None for the same reason as create_speech above.
|
||||
@app.post("/v1/audio/speech/stream", response_model=None)
|
||||
def stream_speech(request: StreamSpeechRequest) -> Response | JSONResponse:
|
||||
if state["status"] != "ready":
|
||||
return fail(503, f"model is not ready: {state['status']}", code="model_not_ready")
|
||||
|
||||
voice = resolve_voice(request.voice)
|
||||
if voice is None:
|
||||
available = ", ".join(engine.available_voices())
|
||||
return fail(
|
||||
400,
|
||||
f"unknown voice {request.voice!r}; available: {available}",
|
||||
param="voice",
|
||||
code="unknown_voice",
|
||||
)
|
||||
|
||||
chunks = engine.iter_audio_chunks(request.input, voice, request.speed or 1.0)
|
||||
try:
|
||||
# Pulled before responding: once the status line is sent it cannot become
|
||||
# a 400, and input with no speakable text has to keep failing that way.
|
||||
first = next(chunks)
|
||||
except StopIteration:
|
||||
return fail(
|
||||
400,
|
||||
"input contains no speakable text for the Russian G2P",
|
||||
param="input",
|
||||
code="no_phonemes",
|
||||
)
|
||||
|
||||
def body() -> Iterator[bytes]:
|
||||
encoder = StreamEncoder(request.response_format)
|
||||
try:
|
||||
encoder.push(first)
|
||||
yield from encoder.drain()
|
||||
for chunk in chunks:
|
||||
encoder.push(chunk)
|
||||
yield from encoder.drain()
|
||||
yield from encoder.finish()
|
||||
except Exception:
|
||||
log.exception("streaming synthesis failed")
|
||||
raise
|
||||
finally:
|
||||
chunks.close()
|
||||
encoder.abort()
|
||||
|
||||
return StreamingResponse(
|
||||
body(),
|
||||
media_type=CONTENT_TYPES[request.response_format],
|
||||
headers={"model-id": MODEL_ID, "voice-id": voice},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
def list_models() -> dict:
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [{"id": MODEL_ID, "object": "model", "created": MODEL_CREATED, "owned_by": "zaakirio"}],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/voices")
|
||||
def list_voices() -> dict:
|
||||
return {
|
||||
"object": "list",
|
||||
"ready": state["status"] == "ready",
|
||||
"data": [
|
||||
{"id": name, "object": "voice", "checkpoint": VOICE_SPECS[name][0], "gender": VOICE_SPECS[name][1]}
|
||||
for name in engine.available_voices()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> JSONResponse:
|
||||
ready = state["status"] == "ready"
|
||||
return JSONResponse(
|
||||
status_code=200 if ready else 503,
|
||||
content={
|
||||
"status": state["status"],
|
||||
"model": MODEL_ID,
|
||||
"voices": engine.available_voices(),
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"error": state["error"],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Bake every kokoro-ru asset the server needs into the image.
|
||||
|
||||
Two things make a plain `FROM python` image useless for this model at runtime,
|
||||
and both are fixed here at build time:
|
||||
|
||||
* kokoro-ru's checkpoints and its recompiled espeak-ng data live in the HF
|
||||
cache by default, and the HF cache is part of the disposable container
|
||||
layer, so every `podman run` would re-download ~700 MB.
|
||||
* ruaccent writes its ONNX models, dictionaries and Koziev data into its own
|
||||
`site-packages/ruaccent` directory. It only downloads when those files are
|
||||
missing, so a single `load()` here means the runtime never touches the
|
||||
network.
|
||||
|
||||
RuG2P resolves espeak-data/ and kokoro-config.json relative to ru_g2p.py, so
|
||||
the snapshot layout has to stay flat inside KOKORO_MODEL_DIR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
REPO = os.environ.get("KOKORO_RU_REPO", "zaakirio/kokoro-ru")
|
||||
# A commit, not a branch: "main" would silently change the weights under a
|
||||
# rebuild that only touched an unrelated line of the Nix module.
|
||||
REVISION = os.environ.get("KOKORO_RU_REVISION", "main")
|
||||
DEST = Path(os.environ.get("KOKORO_MODEL_DIR", "/app/kokoro-ru"))
|
||||
|
||||
VOICES = [v.strip() for v in os.environ.get("KOKORO_RU_VOICES", "sveta,masha,dima").split(",") if v.strip()]
|
||||
|
||||
# sveta and masha share one checkpoint and differ only by voicepack, so the two
|
||||
# female voices cost one 327 MB download, not two.
|
||||
CHECKPOINTS = {
|
||||
"sveta": "kokoro-ru-v2-base.pth",
|
||||
"masha": "kokoro-ru-v2-base.pth",
|
||||
"dima": "kokoro-ru-v2-dima.pth",
|
||||
}
|
||||
|
||||
PATTERNS = [
|
||||
# KModel reads config.json; RuG2P reads kokoro-config.json for the phoneme
|
||||
# vocab. They are not the same file and both are required.
|
||||
"config.json",
|
||||
"kokoro-config.json",
|
||||
"ru_g2p.py",
|
||||
# Stock espeak-ng ru_dict ignores combining-acute stress marks, which is the
|
||||
# one thing this whole front-end exists to fix. The model repo ships a
|
||||
# recompiled dictsource; there is no substitute to fall back to.
|
||||
"espeak-data/**",
|
||||
*(CHECKPOINTS[v] for v in VOICES if v in CHECKPOINTS),
|
||||
*(f"voices/{v}.pt" for v in VOICES),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
|
||||
DEST.mkdir(parents=True, exist_ok=True)
|
||||
snapshot_download(
|
||||
repo_id=REPO,
|
||||
revision=REVISION,
|
||||
allow_patterns=PATTERNS,
|
||||
local_dir=str(DEST),
|
||||
)
|
||||
logging.info("kokoro-ru assets in %s at %s", DEST, REVISION)
|
||||
|
||||
missing = [name for name in VOICES if not (DEST / "voices" / f"{name}.pt").exists()]
|
||||
if missing:
|
||||
raise SystemExit(f"voice packs missing after download: {missing}")
|
||||
|
||||
# Warm ruaccent into site-packages so `load()` short-circuits at runtime.
|
||||
from ruaccent import RUAccent
|
||||
|
||||
accent = RUAccent()
|
||||
accent.load(omograph_model_size="turbo3.1", use_dictionary=True, tiny_mode=False)
|
||||
logging.info("ruaccent warm: %s", accent.process_all("Здравствуйте, как ваши дела?"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
# torch is installed separately in the Dockerfile from the CPU-only index;
|
||||
# do not add it here or pip would pull the ~2.5 GB CUDA build over it.
|
||||
#
|
||||
# ru_g2p.py (from zaakirio/kokoro-ru) imports three things stock kokoro does not
|
||||
# pull on its own: the espeak-ng backend of misaki, ruaccent for stress, and the
|
||||
# phonemizer fork whose EspeakWrapper misaki drives.
|
||||
kokoro==0.9.4
|
||||
misaki[en]>=0.9.4
|
||||
phonemizer-fork
|
||||
espeakng-loader
|
||||
ruaccent
|
||||
|
||||
# kokoro's Albert encoder and ruaccent's ONNX exports both go through
|
||||
# transformers. ru_g2p.py shims token_type_ids for v5, so the floor is what
|
||||
# matters, not the ceiling.
|
||||
transformers>=4.46
|
||||
|
||||
fastapi
|
||||
uvicorn
|
||||
imageio-ffmpeg
|
||||
numpy>=1.26,<3
|
||||
+9
-23
@@ -3,24 +3,15 @@ let
|
||||
# NixOS-only modules. termux runs nix-on-droid (its own module system,
|
||||
# class = "nixOnDroid"): options like services.*, users.*, sops.*, disko.*
|
||||
# and nixpkgs.overlays (flake assertion) do not exist there.
|
||||
moduleArgs = config: {
|
||||
inherit inputs;
|
||||
xlib = config.xlib;
|
||||
};
|
||||
#
|
||||
# `xlib` arrives as a module argument (see lib/mkSystem.nix) and is plain
|
||||
# data, not a module option, so nothing here has to declare or set it.
|
||||
defaultModule =
|
||||
{
|
||||
config,
|
||||
deviceType,
|
||||
lib,
|
||||
xlib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
isDesktop = builtins.elem deviceType [
|
||||
"primary"
|
||||
"secondary"
|
||||
];
|
||||
in
|
||||
{
|
||||
imports =
|
||||
with inputs;
|
||||
@@ -37,33 +28,28 @@ let
|
||||
self.homeConfigurations.default.nixosModule # default homeConfigurations
|
||||
disko.nixosModules.disko # disko module
|
||||
]
|
||||
++ lib.optional isDesktop ./desktop # desktop class: primary/secondary
|
||||
# desktop class: primary/secondary
|
||||
++ lib.optional xlib.isDesktop ./desktop
|
||||
# device-type module dir; "minimal" has no extra modules
|
||||
++ lib.optional (!isDesktop && deviceType != "minimal") (./. + "/${deviceType}");
|
||||
++ lib.optional (!xlib.isDesktop && xlib.device.type != "minimal") (./. + "/${xlib.device.type}");
|
||||
nixpkgs.overlays = with inputs; [
|
||||
self.nixosOverlays.default
|
||||
];
|
||||
networking.hostName = lib.mkDefault config.xlib.device.hostname;
|
||||
_module.args = moduleArgs config;
|
||||
networking.hostName = lib.mkDefault xlib.device.hostname;
|
||||
};
|
||||
strictModule =
|
||||
{
|
||||
config,
|
||||
deviceType,
|
||||
lib,
|
||||
xlib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = with inputs; [
|
||||
imports = [
|
||||
# ./essentials
|
||||
# ./users.nix
|
||||
./options.nix
|
||||
(./. + "/${deviceType}")
|
||||
(./. + "/${xlib.device.type}")
|
||||
# sops-nix.nixosModules.sops
|
||||
];
|
||||
|
||||
_module.args = moduleArgs config;
|
||||
};
|
||||
in
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
config,
|
||||
pkgs,
|
||||
inputs,
|
||||
xlib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
@@ -185,7 +186,7 @@
|
||||
enable = true;
|
||||
config = {
|
||||
user = {
|
||||
name = "oqyude";
|
||||
name = xlib.device.username;
|
||||
email = "oqyude@gmail.com";
|
||||
};
|
||||
pull = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
xlib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
@@ -20,7 +21,7 @@
|
||||
};
|
||||
shellInit = ''
|
||||
beet-p() {
|
||||
local base="/home/oqyude/.config/beets/My"
|
||||
local base="${xlib.dirs.user-home}/.config/beets/My"
|
||||
local rel
|
||||
rel=$(realpath --relative-to="$base" "$PWD")
|
||||
beet mod "path:$rel" playlist="$*"
|
||||
@@ -29,7 +30,7 @@
|
||||
beet im ./ -S $*
|
||||
}
|
||||
beet-path() {
|
||||
realpath --relative-to="/home/oqyude/.config/beets/My" "$1"
|
||||
realpath --relative-to="${xlib.dirs.user-home}/.config/beets/My" "$1"
|
||||
}
|
||||
'';
|
||||
shellAliases = {
|
||||
@@ -45,7 +46,7 @@
|
||||
gc = "git add . && git commit -m 'dev: автокоммит $(date +'%Y-%m-%d %H:%M:%S')'";
|
||||
y = "yazi";
|
||||
nix-shellp = "nix-shell --run $SHELL -p";
|
||||
beet-path-library = "realpath --relative-to='/home/oqyude/.config/beets/My' .";
|
||||
beet-path-library = "realpath --relative-to='${xlib.dirs.user-home}/.config/beets/My' .";
|
||||
z-proxy = "export ALL_PROXY=socks5://localhost:10808";
|
||||
zh-proxy = "export HTTPS_PROXY=http://localhost:10808 && export HTTP_PROXY=http://localhost:10808";
|
||||
|
||||
|
||||
+25
-15
@@ -3,21 +3,31 @@
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
lib.mkIf config.xlib.ssh.enable {
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
allowSFTP = true;
|
||||
openFirewall = lib.mkDefault false;
|
||||
hostKeys = [
|
||||
{
|
||||
path = "/etc/ssh/id_ed25519";
|
||||
type = "ed25519";
|
||||
}
|
||||
];
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
PermitRootLogin = "yes";
|
||||
UsePAM = true;
|
||||
{
|
||||
options.host.ssh = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable the SSH server with the shared config below.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf config.host.ssh.enable {
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
allowSFTP = true;
|
||||
openFirewall = lib.mkDefault false;
|
||||
hostKeys = [
|
||||
{
|
||||
path = "/etc/ssh/id_ed25519";
|
||||
type = "ed25519";
|
||||
}
|
||||
];
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
PermitRootLogin = "yes";
|
||||
UsePAM = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
description = "Prebuild NixOS closure";
|
||||
serviceConfig = {
|
||||
CPUQuota = "20%";
|
||||
User = "oqyude";
|
||||
User = xlib.device.username;
|
||||
Group = "users";
|
||||
Nice = 10;
|
||||
Type = "oneshot";
|
||||
WorkingDirectory = "/tmp";
|
||||
Environment = [
|
||||
"HOME=/home/oqyude"
|
||||
"HOME=${xlib.dirs.user-home}"
|
||||
];
|
||||
ExecStart = ''
|
||||
${pkgs.nix}/bin/nix build --no-link /etc/nixos#nixosConfigurations.${config.networking.hostName}.config.system.build.toplevel
|
||||
|
||||
+68
-101
@@ -1,109 +1,76 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Option factory for the xlib.dirs namespace
|
||||
mkDir =
|
||||
default: description:
|
||||
lib.mkOption {
|
||||
type = lib.types.str;
|
||||
inherit default description;
|
||||
};
|
||||
|
||||
helpers = import ../lib/xlib.nix { inherit lib; };
|
||||
in
|
||||
# Cross-module options: declared here, not in the module that reads them.
|
||||
#
|
||||
# An option belongs in this file when at least one context *sets* it while
|
||||
# another module *reads* it — the reader cannot be the only place that knows
|
||||
# the option exists. `modules/essentials/ssh.nix` does not belong here: it
|
||||
# declares and reads `host.ssh.enable` itself, within one module.
|
||||
{
|
||||
options = {
|
||||
xlib = {
|
||||
device = {
|
||||
type = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"minimal"
|
||||
"primary"
|
||||
"secondary"
|
||||
"server"
|
||||
"vds"
|
||||
"wsl"
|
||||
"termux"
|
||||
];
|
||||
default = "minimal";
|
||||
description = "Type of device for this host.";
|
||||
};
|
||||
username = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "oqyude";
|
||||
description = "Username for host.";
|
||||
};
|
||||
hostname = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "nixos";
|
||||
description = "Hostname...";
|
||||
};
|
||||
};
|
||||
ssh = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable SSH server with the standard config.";
|
||||
};
|
||||
};
|
||||
dirs = {
|
||||
user-home = mkDir "/home/${config.xlib.device.username}" "User home directory.";
|
||||
user-storage = mkDir "${config.xlib.dirs.user-home}/Storage" "User storage directory.";
|
||||
archive-drive = mkDir "/mnt/archive" "Archive drive mount point.";
|
||||
lamet-drive = mkDir "/mnt/lamet" "Lamet drive mount point.";
|
||||
mobile-drive = mkDir "/mnt/mobile" "Mobile drive mount point.";
|
||||
therima-drive = mkDir "/mnt/therima" "Therima drive mount point.";
|
||||
vetymae-drive = mkDir "/mnt/vetymae" "Vetymae drive mount point.";
|
||||
soptur-drive = mkDir "/mnt/soptur" "Soptur drive mount point.";
|
||||
wsl-home = mkDir "/mnt/c/Users/${config.xlib.device.username}" "WSL home directory.";
|
||||
wsl-storage = mkDir "${config.xlib.dirs.wsl-home}/Storage" "WSL storage directory.";
|
||||
server-home = mkDir "/home/${config.xlib.device.username}/External" "Server home directory.";
|
||||
server-credentials = mkDir "${config.xlib.dirs.server-home}/Credentials/server" "Server credentials directory.";
|
||||
storage = mkDir "${config.xlib.dirs.server-home}/Storage" "General storage directory.";
|
||||
calibre-library = mkDir "${config.xlib.dirs.server-home}/Books-Library" "Calibre library directory.";
|
||||
music-library = mkDir "${config.xlib.dirs.user-home}/Music" "Music library directory.";
|
||||
services-folder = mkDir "${config.xlib.dirs.server-home}/Services" "All services folder.";
|
||||
services-mnt-folder = mkDir "/mnt/services" "All services folder.";
|
||||
services-nodes-folder = mkDir "${config.xlib.dirs.services-mnt-folder}/nodes" "All nodes folder.";
|
||||
postgresql-folder = mkDir "${config.xlib.dirs.services-mnt-folder}/postgresql" "PostgreSQL service folder.";
|
||||
};
|
||||
helpers = lib.mkOption {
|
||||
type = lib.types.anything;
|
||||
default = helpers;
|
||||
description = "Shared helper functions (see lib/xlib.nix).";
|
||||
};
|
||||
services."3x-ui" = {
|
||||
# Domain whose Let's Encrypt cert (at /var/lib/acme/<domain>/)
|
||||
# gets mounted read-only into the 3x-ui container so the panel
|
||||
# can terminate TLS itself. 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).
|
||||
'';
|
||||
};
|
||||
};
|
||||
# Remote-builder wiring. A coordinator (e.g. sapphira) sets
|
||||
# `host.builder.clients` to register remote build machines;
|
||||
# a builder host (e.g. the WSL on vetymae) sets `host.builder.enable`
|
||||
# to advertise itself. The two halves are intentionally split so a single
|
||||
# declaration in configurations/* is enough to flip each side.
|
||||
options.host.builder = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Advertise this host as a remote Nix builder and accept builds
|
||||
from other machines in the flake over SSH.
|
||||
'';
|
||||
};
|
||||
clients = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.attrs;
|
||||
default = [ ];
|
||||
description = ''
|
||||
List of remote Nix build machines this coordinator should
|
||||
register via `nix.buildMachines`. Each entry matches the NixOS
|
||||
option schema (hostName, sshUser, sshKey, systems,
|
||||
supportedFeatures, ...). Two extra attributes are consumed by
|
||||
modules/server/builder.nix and stripped before reaching
|
||||
`nix.buildMachines`:
|
||||
- `proxyCommand` — generates a per-builder Host block in the
|
||||
system-wide OpenSSH config (the nix-daemon runs as root and
|
||||
cannot see the user's ~/.ssh/config).
|
||||
- `hostKeyAlias` — alias used inside that SSH matchBlock.
|
||||
A builder reachable on its own (no ProxyCommand needed) omits
|
||||
both and gets no SSH matchBlock. Empty by default — opt in by
|
||||
setting this list.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
options.host."3x-ui" = {
|
||||
# Domain whose Let's Encrypt cert (at /var/lib/acme/<domain>/)
|
||||
# gets mounted read-only into the 3x-ui container so the panel
|
||||
# can terminate TLS itself. 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).
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# sapphira (and any other server-class coordinator) — register remote
|
||||
# builders, and make sure the nix daemon (running as root) can resolve the
|
||||
# SSH host alias with its ProxyCommand chain.
|
||||
#
|
||||
# The `host.builder.clients` option itself is declared in
|
||||
# modules/options.nix (cross-module). The actual builder list is set by the
|
||||
# configuration (e.g. configurations/server.nix) — this module is generic
|
||||
# over every entry on the list.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Attributes that belong to the SSH matchBlock only — NOT to
|
||||
# `nix.buildMachines` (that schema has no hostKeyAlias/proxyCommand).
|
||||
# Strip them before handing the list to nix.buildMachines.
|
||||
sshOnlyAttrs = [
|
||||
"hostKeyAlias"
|
||||
"proxyCommand"
|
||||
];
|
||||
forNix = b: removeAttrs b sshOnlyAttrs;
|
||||
# After NixOS's nix.buildMachines submodule runs, each entry has all
|
||||
# attributes defaulted (protocol=ssh, systems=[], etc.). Read from that
|
||||
# processed list so the formatter never trips on a missing field.
|
||||
processedBuilders = config.nix.buildMachines;
|
||||
|
||||
# Serialise one builder to the textual format Nix's daemon expects in
|
||||
# `nix.conf`'s `builders` line. Mirrors `buildMachinesText` from
|
||||
# nixos/modules/config/nix-remote-build.nix so the result is identical
|
||||
# to what NixOS writes to /etc/nix/machines — we just inline it instead
|
||||
# of relying on `@/etc/nix/machines`, which Nix 2.34 parses but does
|
||||
# not act on (the daemon's `external-builders` list stays empty and the
|
||||
# client reports "configure remote builders via 'builders'" forever).
|
||||
formatBuilder = b:
|
||||
let
|
||||
# Nix 2.34 refuses to dispatch derivations to a builder whose protocol
|
||||
# is `ssh` (the NixOS default): the daemon leaves `external-builders`
|
||||
# empty even when the `builders` line is well-formed, and the client
|
||||
# falls back to local. `ssh-ng` (the new in-band protocol) actually
|
||||
# opens the dispatcher. Override the NixOS default here.
|
||||
proto = "ssh-ng://";
|
||||
user = if b.sshUser != null && b.sshUser != "" then "${b.sshUser}@" else "";
|
||||
systems =
|
||||
if b.system != null then b.system
|
||||
else if b.systems != [ ] then lib.concatStringsSep "," b.systems
|
||||
else "-";
|
||||
sshKey = if b.sshKey != null && b.sshKey != "" then b.sshKey else "-";
|
||||
maxJobs = toString b.maxJobs;
|
||||
speedFactor = toString b.speedFactor;
|
||||
allFeats = b.supportedFeatures ++ b.mandatoryFeatures;
|
||||
supported =
|
||||
if allFeats == [ ] then "-"
|
||||
else lib.concatStringsSep "," allFeats;
|
||||
mandatory =
|
||||
if b.mandatoryFeatures == [ ] then "-"
|
||||
else lib.concatStringsSep "," b.mandatoryFeatures;
|
||||
publicKey = if b.publicHostKey != null then b.publicHostKey else "-";
|
||||
in
|
||||
lib.concatStringsSep " " [
|
||||
"${proto}${user}${b.hostName}"
|
||||
systems
|
||||
sshKey
|
||||
maxJobs
|
||||
speedFactor
|
||||
supported
|
||||
mandatory
|
||||
publicKey
|
||||
];
|
||||
inlineBuilders = lib.concatMapStringsSep "\n" formatBuilder processedBuilders;
|
||||
|
||||
# One OpenSSH host block per builder that needs a ProxyCommand.
|
||||
# Placed in `programs.ssh.extraConfig` so it ends up in
|
||||
# /etc/ssh/ssh_config (the file OpenSSH consults system-wide, including
|
||||
# for the nix-daemon running as root).
|
||||
#
|
||||
# Only builders with a `proxyCommand` attribute get a block: a builder
|
||||
# reachable on its own (e.g. otreca on a public IP) needs no help from
|
||||
# here. The attribute is the literal ProxyCommand string (passed
|
||||
# verbatim to ssh); the configuration is responsible for matching it
|
||||
# with the `hostName` field.
|
||||
hostBlock = b: ''
|
||||
Host ${b.hostName}
|
||||
User ${b.sshUser}
|
||||
HostKeyAlias ${b.hostKeyAlias or b.hostName}
|
||||
ProxyCommand ${b.proxyCommand}
|
||||
StrictHostKeyChecking accept-new
|
||||
ServerAliveInterval 30
|
||||
ServerAliveCountMax 3
|
||||
ControlMaster auto
|
||||
ControlPersist 60
|
||||
ConnectTimeout 15
|
||||
'';
|
||||
blocks = map hostBlock (lib.filter (b: b ? proxyCommand) config.host.builder.clients);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (config.host.builder.clients != [ ]) {
|
||||
# Off-by-default in NixOS. Without this, the nix-remote-build module
|
||||
# sets `nix.settings.builders = null` and the list is dropped from
|
||||
# /etc/nix/nix.conf entirely, even though `nix.buildMachines` is
|
||||
# populated. (The build-machine list still lands in /etc/nix/machines
|
||||
# but nix-daemon reads `builders`, not /etc/nix/machines, when
|
||||
# distributedBuilds is false.)
|
||||
nix.distributedBuilds = true;
|
||||
nix.buildMachines = map forNix config.host.builder.clients;
|
||||
# Nix 2.34's daemon does not act on `@/etc/nix/machines` (the file
|
||||
# format NixOS's nix-remote-build writes to): the `builders` config
|
||||
# key is parsed for display but `external-builders` stays empty and
|
||||
# the scheduler ignores it. Inlining the same builder text here — in
|
||||
# the exact format the NixOS module itself uses — actually wires up
|
||||
# the SSH dispatch. `mkForce` is required because the nix-remote-build
|
||||
# module sets `builders = null` whenever distributedBuilds is *false*;
|
||||
# our config flips it to *true*, so the module's mkIf does not fire
|
||||
# and there is no actual conflict — but pinning it with mkForce makes
|
||||
# the intent obvious and survives any future change in default
|
||||
# behaviour.
|
||||
nix.settings.builders = lib.mkForce inlineBuilders;
|
||||
|
||||
# Append per-builder Host blocks to the system-wide OpenSSH client
|
||||
# config. `programs.ssh.extraConfig` is of type `lines`, merged across
|
||||
# modules, and prepended (before `Host *`) in /etc/ssh/ssh_config —
|
||||
# which is exactly the spot where specific Host blocks have to live.
|
||||
programs.ssh.extraConfig = lib.concatStrings blocks;
|
||||
|
||||
# Parallel builds on sapphira itself stay at 2 — that matches the
|
||||
# physical cores and keeps the coordinator responsive while the WSL
|
||||
# absorbs the heavy lifting. The essentials/settings.nix already
|
||||
# leaves max-jobs at the default `auto` (2 here); no override needed.
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
../pkgs/beets.nix
|
||||
./acme.nix
|
||||
./bentopdf.nix
|
||||
./builder.nix
|
||||
./calibre-web.nix
|
||||
./chrony.nix
|
||||
./coredns.nix
|
||||
@@ -50,7 +51,7 @@
|
||||
# there are other vhosts on the same port). Cert is still mounted in
|
||||
# case 3x-ui is later reconfigured to terminate TLS itself (e.g. for
|
||||
# direct node-API access); nginx doesn't have to use it.
|
||||
xlib.services."3x-ui".certDomain = "x.zeroq.su";
|
||||
host."3x-ui".certDomain = "x.zeroq.su";
|
||||
systemd.tmpfiles.rules = [
|
||||
(xlib.helpers.mkTmpfile "d" "/mnt" "0755" "root" "root")
|
||||
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
dbtype = "pgsql";
|
||||
dbuser = "nextcloud";
|
||||
dbname = "nextcloud";
|
||||
adminuser = "oqyude";
|
||||
adminuser = xlib.device.username;
|
||||
adminpassFile = config.sops.secrets.nextcloud-adminpass.path;
|
||||
};
|
||||
settings = {
|
||||
|
||||
@@ -194,6 +194,17 @@ in
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
# sapphira itself: opencode web runs as a systemd user service
|
||||
# (programs.opencode.web.enable in home/modules/opencode.nix) on
|
||||
# 127.0.0.1:4096 with --hostname 0.0.0.0.
|
||||
"opencode.zeroq.su" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:4096";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
"nextcloud.zeroq.su" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
|
||||
+58
-6
@@ -8,7 +8,9 @@ let
|
||||
user = "${xlib.device.username}";
|
||||
userGroup = config.users.users."${user}".group;
|
||||
|
||||
# sops secret factory: name == key by default, owner/group default to root
|
||||
# sops secret factory: name == key by default, owner/group default to root.
|
||||
# `format` and `sopsFile` default to yaml + defaultSopsFile, matching every
|
||||
# pre-existing caller.
|
||||
mkSecret =
|
||||
{
|
||||
path,
|
||||
@@ -16,14 +18,16 @@ let
|
||||
key ? null,
|
||||
owner ? null,
|
||||
group ? null,
|
||||
format ? "yaml",
|
||||
sopsFile ? null,
|
||||
}:
|
||||
{
|
||||
format = "yaml";
|
||||
inherit path mode;
|
||||
inherit format path mode;
|
||||
}
|
||||
// lib.optionalAttrs (key != null) { inherit key; }
|
||||
// lib.optionalAttrs (owner != null) { inherit owner; }
|
||||
// lib.optionalAttrs (group != null) { inherit group; };
|
||||
// lib.optionalAttrs (group != null) { inherit group; }
|
||||
// lib.optionalAttrs (sopsFile != null) { inherit sopsFile; };
|
||||
|
||||
# default owner = device user
|
||||
mkUserSecret =
|
||||
@@ -37,8 +41,6 @@ let
|
||||
);
|
||||
in
|
||||
{
|
||||
xlib.device.username = "oqyude";
|
||||
|
||||
users = {
|
||||
mutableUsers = false;
|
||||
users = {
|
||||
@@ -46,10 +48,31 @@ in
|
||||
name = "${user}";
|
||||
isNormalUser = true;
|
||||
group = "users";
|
||||
# Pinned, not left to NixOS' nextfree logic: the ntfs3/exfat mount
|
||||
# helpers (lib/xlib/helpers.nix) bake xlib.device.uid into their mount
|
||||
# options, so normally both sides agree and NTFS/exFAT files do not
|
||||
# show up as owned by `nobody`. NixOS has no per-user `gid` option —
|
||||
# the primary group id comes from `group` above.
|
||||
#
|
||||
# sapphira is the one exception, and only until its filesystem gets
|
||||
# migrated: /var/lib/nixos/uid-map still reserves 1000 for the
|
||||
# long-removed `yuyus` and NixOS never renumbers an existing user, so
|
||||
# the live `oqyude` there is uid 1001. Without this branch a rebuild
|
||||
# would rewrite the user to 1000 while every file is still owned by
|
||||
# 1001. The cost: the exFAT mounts on sapphira still get uid=1000 from
|
||||
# xlib.device.uid, so that user cannot write to /mnt/archive or
|
||||
# /mnt/mobile until the id question is settled.
|
||||
# TODO: delete this branch once sapphira is migrated to 1000.
|
||||
uid = if xlib.device.hostname == "sapphira" then 1001 else xlib.device.uid;
|
||||
description = "Jor Oqyude";
|
||||
hashedPasswordFile = config.sops.secrets.hashed_password.path; # hashed_password
|
||||
homeMode = "700";
|
||||
home = "/home/${user}";
|
||||
# Linger keeps `user@<uid>.service` (the systemd user manager) alive
|
||||
# across logouts, so user services like opencode-web survive when no
|
||||
# SSH/login session is active. Without this the service is torn down
|
||||
# together with the user manager on the last session close.
|
||||
linger = true;
|
||||
extraGroups = [
|
||||
"audio"
|
||||
"disk"
|
||||
@@ -84,6 +107,35 @@ in
|
||||
path = "${xlib.dirs.user-home}/.config/sops/age/keys.txt";
|
||||
mode = "0600";
|
||||
};
|
||||
# opencode web server creds + Gemini API key.
|
||||
# Decrypted as a single dotenv file (no `key`) and consumed by the
|
||||
# systemd user unit opencode-web as EnvironmentFile.
|
||||
# Source: secrets/opencode.env (encrypted, see sops/age below).
|
||||
opencode_server = mkUserSecret {
|
||||
path = "${xlib.dirs.user-home}/.config/opencode/server.env";
|
||||
mode = "0600";
|
||||
format = "dotenv";
|
||||
sopsFile = ../secrets/opencode.env;
|
||||
};
|
||||
# opencode provider credentials (XDG_DATA_HOME/opencode/...).
|
||||
# Both files are read by opencode at startup to populate the providers
|
||||
# list. Mirror of ~/.local/share/opencode/ on the workstation.
|
||||
# key = "" → decrypt the WHOLE file as-is (the JSON has no top-level
|
||||
# field named after the secret; it IS the secret).
|
||||
opencode_auth = mkUserSecret {
|
||||
path = "${xlib.dirs.user-home}/.local/share/opencode/auth.json";
|
||||
mode = "0600";
|
||||
format = "json";
|
||||
sopsFile = ../secrets/opencode-auth.json;
|
||||
key = "";
|
||||
};
|
||||
opencode_account = mkUserSecret {
|
||||
path = "${xlib.dirs.user-home}/.local/share/opencode/account.json";
|
||||
mode = "0600";
|
||||
format = "json";
|
||||
sopsFile = ../secrets/opencode-account.json;
|
||||
key = "";
|
||||
};
|
||||
ssh_key_private = mkUserSecret {
|
||||
path = "${xlib.dirs.user-home}/.ssh/id_ed25519";
|
||||
mode = "0600";
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
];
|
||||
# VDS hosts the public-facing Xray REALITY inbound on container:443,
|
||||
# fronted by nginx stream on host:443 → host:15380 → container:443.
|
||||
xlib.services."3x-ui" = {
|
||||
host."3x-ui" = {
|
||||
certDomain = "pubray1.zeroq.su";
|
||||
reality443Forwarding = true;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}:
|
||||
let
|
||||
serviceName = "rsync-services-sync";
|
||||
serverAddress = "oqyude@100.64.0.0";
|
||||
serverAddress = "${xlib.device.username}@100.64.0.0";
|
||||
serverDir = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}";
|
||||
nodeDir = "${xlib.dirs.services-mnt-folder}";
|
||||
in
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# WSL NixOS — advertise this host as a remote Nix builder.
|
||||
#
|
||||
# Why a dedicated module instead of inlining into configurations/wsl.nix:
|
||||
# every "what makes this WSL different from a desktop/server" concern
|
||||
# belongs under modules/wsl/ — that is the contract the device-type import in
|
||||
# modules/default.nix wires up. Keeping it here means flipping the feature on
|
||||
# later on another WSL host (e.g. a future vetymae-2) is one import away.
|
||||
#
|
||||
# The `host.builder.enable` option itself is declared in
|
||||
# modules/options.nix (cross-module).
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
config = lib.mkIf config.host.builder.enable {
|
||||
# WSL2 does not expose /dev/kvm to the guest (no nested virt by default,
|
||||
# and Hyper-V's /dev/kvm is not bind-mounted into the WSL namespace).
|
||||
# The default NixOS module advertises `kvm nixos-test benchmark
|
||||
# big-parallel` as this host's system-features, which is a lie: any
|
||||
# derivation that requires `kvm` will be dispatched here and immediately
|
||||
# fail with "cannot open /dev/kvm". Nix selects builders by matching the
|
||||
# derivation's required features against what the builder advertises, so
|
||||
# the only way to keep WSL useful is to retract the features it cannot
|
||||
# actually deliver. `nixos-test` is dropped for the same reason — it
|
||||
# wants kvm anyway.
|
||||
#
|
||||
# `mkForce` because the NixOS module base-config sets a non-empty
|
||||
# default; without force the lists would concatenate and the WSL would
|
||||
# *still* advertise kvm.
|
||||
nix.settings.system-features = lib.mkForce [
|
||||
"benchmark"
|
||||
"big-parallel"
|
||||
];
|
||||
|
||||
# Builds arrive over SSH as the user `oqyude` (see
|
||||
# modules/server/builder.nix). On the default trusted-users = ["root"]
|
||||
# only root can call nix-store, so the SSH session would fail to realise
|
||||
# any .drv. Adding the SSH user to trusted-users lets the remote nix-build
|
||||
# driver drive nix-store on the builder side. `mkForce` for the same
|
||||
# concatenation reason as above.
|
||||
nix.settings.trusted-users = lib.mkForce [
|
||||
"root"
|
||||
"oqyude"
|
||||
];
|
||||
|
||||
# The local daemon already parallelises across all 24 logical cores
|
||||
# (max-jobs = 24 is what we measured). When acting as a builder, we
|
||||
# want to keep that — remote builds land through SSH and the daemon
|
||||
# serves them on top of its normal pool. No override needed; documented
|
||||
# here so a future reader does not "tidy up" by setting max-jobs low.
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
{
|
||||
imports = [
|
||||
# shared container modules live in ../../containers
|
||||
# ../../containers/3x-ui.nix
|
||||
../../containers/kokoro-tts.nix
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
../pkgs/beets.nix
|
||||
./containers
|
||||
./nix-serve.nix
|
||||
./builder.nix
|
||||
# ./tools
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": "ENC[AES256_GCM,data:Og==,iv:7CSdsBk5u2UKOn1dE6CYOiPlmYYkUmmxV1VD6nVIIoc=,tag:z1z57WidbvdlIY5CHQU/TA==,type:int]",
|
||||
"accounts": {
|
||||
"fa02561b1001pVHOSO4a6JW9Cv": {
|
||||
"id": "ENC[AES256_GCM,data:Xm+h0mYIkOAhRI2MZt/nNxMZ+UW7twFu3a4=,iv:PQlE5hc5UPpShQju31AjNKlmaBovCH0eKGnMi1wIfwg=,tag:P1qA5OAQMyICDk52m3jCfA==,type:str]",
|
||||
"serviceID": "ENC[AES256_GCM,data:5S0wMZ1ES5GjSnp1uXhuxLdjlA==,iv:6DvaCiDjBo/tKpjVSazxSNtticZjAmrcA00jjzXsKmc=,tag:S24kxmT6GJyoKSywJGCYlw==,type:str]",
|
||||
"description": "ENC[AES256_GCM,data:HEISFOphDA==,iv:3IvEjXPs1RA0xeOO2k3ECdGUXb55ueR0yxJSdWDqqho=,tag:YgtEMx7nPxgY4xjr8B3isA==,type:str]",
|
||||
"credential": {
|
||||
"type": "ENC[AES256_GCM,data:kdOU,iv:8umxWXKvaDqYO5woOQDqTuuwtdgJ7oVJD8XU7D841k4=,tag:QBRDcCCIqbfbvY3d2CD67w==,type:str]",
|
||||
"key": "ENC[AES256_GCM,data:PGzIgMDQkclf4RiDO3lQE/fQ4V0hM6nvFB/XpUqdMiAKvW/cQyCdmxdwwJQe4FSPHwyYzYDfi0VULf/tfYq+hOx5mC5F0/9ldLw2cbf68TPdcCMjIZEokLUAqe0qJlTnGxdO4PRzzL1LvOKr3Mlo4KcgoUpmeFPxwvxL2Wk=,iv:Z4gna9cUuz3YjtS2v0+WsKmJV66dryl+XtBdLbUGhrI=,tag:WPgVnEFfrJtG2pXRHGXVDQ==,type:str]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"active": {
|
||||
"minimax-coding-plan": "ENC[AES256_GCM,data:Bt0Yhiz5qmJ1BIU8bDle7mVtyVC6r/lX4gY=,iv:CRmuL1bL0Jc+RFeoSbZyyIHSyBd/RojNjzzVRRODFjs=,tag:mOdKWxDWQLgYSVYiM6hugw==,type:str]"
|
||||
},
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyUk03UnVZOUtKcDJhTUdy\nMUhBcDNROGxHR1RPcEZjeHdnYmtWck5KcVZnClIvckVxZmdBQWg3b0FsVFRVRVBP\nRUVaVTFqeDFQbVYvNk42MnlFVlJpTmsKLS0tIFBBaWJwb09ySGFGUHZsajhlb01v\nek10Q3FBWUM5Wms0UUFtV1p1b29mL1kK+hr3lbwBDmtedEeA0hnXSAxC/HOE/9iz\n5kMSbzno+UXnVG/EfLHsks2G43caBmCZlUp7spTt5DLnpwL923GnFQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-10-03T13:34:07Z",
|
||||
"mac": "ENC[AES256_GCM,data:e+4yZeDnprtRi1jkP8A9DxNOjemIIi9VwOANAnT2f1UEJVBm6v3MPrlLPZ3Kyg7I2wnIw25Ih6boDn92fxWrIut8PmFPFmlCUu0v4mK49YQmB4dA/i/RYQMAZ6pG2JtPMUWOYsHppU67RB/hKQmJigZSz49tBOHiDrgUa+kfK9c=,iv:872D82r8gIl+mMYNy7iEhnxG/1HwrNg1TO6QXIf7Vo4=,tag:Sd8pSaYZhRxRY6jUL9DxhQ==,type:str]",
|
||||
"unencrypted_suffix": "_unencrypted",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"minimax-coding-plan": {
|
||||
"type": "ENC[AES256_GCM,data:cW4a,iv:giJwLOEm5ZoyX1fly0R0xTUsMqtAClmpuSk6d9kaHb4=,tag:YrR/kW3ZFOxot9WeP9kibw==,type:str]",
|
||||
"key": "ENC[AES256_GCM,data:SvZTe8f3/Es1/DOLpcXuY7IyJpLlkuEN38wUd1uBdOdkzA9QZxkroQ93fuvyqSDFAIfDEfSQsAElME3VzaFVB0Y8t97wB2gXWm4xHXjFyzcu+uaOq/deBQ+B13/xMFfjsMlKR1H1WJ4VRZYY3sc70Wsvid+6hxOdO/a/i3k=,iv:x53HYXFeQ9NEMr5SDM8KEOsrzIMzdNAtSeY26FdKkMw=,tag:uW2xCO+cA7nKHWHpBZCVkw==,type:str]"
|
||||
},
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlSkw3Z0VrTjBBa1VsQlRp\nbHUrZ3hXb1Q1Z0kxdVJhV1hVWnNLUUdDclhjCjg0aTNuUlhNNzdFajhPNE1DN2Fn\nZzJZNVRUYUxpNDFJK3pLTkE4UWFsbkUKLS0tIE8wUkZuN21aaXhpZlNzUnBZR0tC\nU2xwcjRJNnRPdTJ6elRhS08ybjlOS1kKBIfDuZSIOFoxCUc21GnqxipT0ouxDHsB\nXGBvj8zkJ+07tDVMYAhjWYkQK9wBNtUMvgxKedvLZNIn0Hm0Z0rPkw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-10-03T13:34:07Z",
|
||||
"mac": "ENC[AES256_GCM,data:lUAw+AL62sxoy695aV1lYgUm4JQwzC+7c36vupe/mJCeNNzVm5ZadGaGsno28EmF4fGxOVsJzn939nhNRtQZ6MwZNhShoSZBmpO51vHRm1YsfAR1sWi/u9akbcu906fjrJLyql9sWWmnxiqxn70gq4Ov6ptsx0VjtiwyWOk+cps=,iv:zckNKtUMu+4DKYp9hwbMPtm6bh46iRNGguff3AIfOa0=,tag:b7svS76JLEJmb+gkPNhQhQ==,type:str]",
|
||||
"unencrypted_suffix": "_unencrypted",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
OPENCODE_SERVER_USERNAME=ENC[AES256_GCM,data:6dqD4TnURrk=,iv:UUOBwiykDe9Wv/78wmmx5JnJEWScQRQh2yM+806lF7Y=,tag:vpSB652uQ+ASZQh4PS12Sw==,type:str]
|
||||
OPENCODE_SERVER_PASSWORD=ENC[AES256_GCM,data:mAIHJ5+pupEFdbTurc6davXecgPrHA==,iv:n3BNhwxbJJ6WVq02ANUi0nNAploCsPQIF/JBT1TXxHg=,tag:2YKnOytFny9x8rg8X/7nxA==,type:str]
|
||||
GEMINI_API_KEY=ENC[AES256_GCM,data:hKbpsv1ZrhROPMHYUUAc/oErz0JPSORLr7/B8N1VgbqVZ1FoVf7LM5o=,iv:+3hzXJkjSwpBdwB231PnuE7T+c7A6bmYCckKAqljO0Q=,tag:VVKsFxptQEg6GZAdCQ1A+g==,type:str]
|
||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPZFdZZUZxOXRXUUF1NVdV\nRmUrQ2FqeGJWdTZvVkxncEpBb3FpaW5VUEM4Ck1WQ0xLVzBrOG1IOER1dXhxZUEy\nTnV3SnlFSi83b3VGdWtQZ0hYeXRyNEUKLS0tIHhqai9mYVRmR091S0w5cmNMK0Y0\nZVVUdzB6Ky9PUFExclBNcnZUQWFrOXcKQq4qlRz5+1GR3LB9/CkZSz1nyFthk7mg\n2j3wUXQ41kyRUu8pM40eCxHVkpMaa/7fjZ40nRjrnwZ7Brd5Q8z3MQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
|
||||
sops_lastmodified=2026-10-03T12:47:19Z
|
||||
sops_mac=ENC[AES256_GCM,data:thYwpX+SrNRL3hdvUzXPzh3Q07Dt6ZF6cplKS5Iopj7twS5lQoB4C1OuWf00GUUPDEOaxF3WK24XiRkMoA8TZHSLJ/k8HPV9tDlPnNHMh3pMj9pIfR5NTDMASmld17PjBvwPMOB/uvMn26O1G6G3ImW4Zioy3AyDP2zmed9+3Xk=,iv:uKGvvwvDTEQom636P9YcUjLMpIrRusCFI9HJqNJihkw=,tag:Qfc5KRSNn+Yy74pKKvkjOA==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.13.3
|
||||
Reference in New Issue
Block a user