Compare commits

...

86 Commits

Author SHA1 Message Date
993b9c559c fix: restrict docker commands for ai-worker (wrapper blacklist)
SECURITY CHANGE: Keep ai-worker in docker group but block dangerous
docker subcommands via a wrapper script.

Approach:
- docker group membership preserved (ps, start, stop, compose still work)
- Docker binary wrapped with a script that blocks dangerous subcommands
- BLOCKED: exec, cp, commit, diff, export, import, load, save, attach, push, tag
- ALLOWED: ps, images, inspect, logs, start, stop, restart, rm, rmi,
  pull, build, run, compose, system, network ls, volume ls

The wrapper is installed in both system packages and ai-worker's
personal profile to ensure it takes precedence over the real docker.
This is effective for the LLM agent threat model — the agent uses CLI
commands and blocked subcommands simply return an error.

Files modified:
- users/ai-worker.nix — restored docker group, kept sudo audit rules
- modules/nixos/security/ai-worker-restricted.nix — added docker wrapper
  script with blacklist logic and NixOS module integration
- modules/nixos/security/README-ai-worker.md — documentation update
2026-05-20 20:42:32 -04:00
36359de6aa Merge pull request 'feat: add Syncthing firewall port and update compose submodule' (#47) from feat/syncthing-org-sync into master
Reviewed-on: #47
2026-05-19 00:34:42 +00:00
Robert
10b8565fd6 Merge branch 'master' into feat/syncthing-org-sync 2026-05-18 20:33:29 -04:00
Robert
f672696b8e Update submodule for syncthing 2026-05-18 20:31:07 -04:00
0980dca455 fix: update compose submodule to Traefik-routed Syncthing 2026-05-14 21:40:12 -04:00
96bc20ab70 feat: add Syncthing firewall port and update compose submodule 2026-05-14 21:36:26 -04:00
670ae4f002 Merge pull request 'fix: update compose submodule — use ln -sf for iptables-nft' (#46) from fix/vpn-iptables-nft-v3 into master
Reviewed-on: #46
2026-05-13 17:00:16 +00:00
f785abfd49 fix: update compose submodule — use ln -sf for iptables-nft 2026-05-13 12:59:04 -04:00
6f44aa7f76 Merge pull request 'fix: update compose submodule — remove apk add iptables-nft' (#45) from fix/vpn-iptables-nft-v2 into master
Reviewed-on: #45
2026-05-13 16:49:39 +00:00
8d40f1691f fix: update compose submodule — remove apk add iptables-nft 2026-05-13 12:49:14 -04:00
Robert
2dd2e64986 Merge remote-tracking branch 'origin/master' 2026-05-13 12:42:54 -04:00
Robert
23fc5e0597 Give a little more ssh room for tramp 2026-05-13 12:41:09 -04:00
0c9c33d735 Merge pull request 'fix: update wg-easy to official ghcr image with iptables-nft' (#44) from fix/vpn-iptables-nft-upstream into master
Reviewed-on: #44
2026-05-13 16:39:56 +00:00
0bb6890f1c chore: merge master into branch 2026-05-13 12:39:05 -04:00
9d5434425f fix: update compose submodule for wg-easy iptables-nft fix
Updates the assets/compose submodule to point to the fix/vpn-iptables-nft-upstream
branch which contains:
- Switch FROM weejewel/wg-easy:latest (Alpine 3.11, stale 4yr) to
  ghcr.io/wg-easy/wg-easy:latest (actively maintained, Alpine krypton)
- Use update-alternatives instead of raw ln -sf to flip iptables
  from legacy to nftables backend
- Fix compose build context: ./vpn -> . (Dockerfile is at same level)
2026-05-13 12:30:47 -04:00
1fb4320dd1 Merge pull request 'feat: update compose submodule for custom tools startup' (#43) from feat/update-compose-submodule-custom-tools into master
Reviewed-on: #43
2026-05-13 13:58:27 +00:00
51e9f47fd4 feat: update compose submodule for custom tools startup 2026-05-13 09:56:24 -04:00
06b3eb840f fix: update compose submodule for wg-easy iptables-nft fix 2026-05-12 16:29:51 -04:00
28ab52209c Merge pull request 'Add restricted AI worker access with deployment capabilities' (#1) from ai-worker-restricted-access into master
Reviewed-on: #1
2026-05-11 00:48:29 +00:00
Robert
e6f7f0c263 Merge branch 'ai-worker-restricted-access' of ssh://code.lazyworkhorse.net:2222/gortium/infra into ai-worker-restricted-access 2026-05-10 18:06:46 -04:00
Robert
5c136e0765 Merge remote-tracking branch 'origin/master' into ai-worker-restricted-access 2026-05-10 17:06:58 -04:00
Robert
f722af7803 New ollama model creator module version 2026-05-10 16:56:09 -04:00
Robert
c07debf088 Added wireguard keys 2026-05-10 16:51:32 -04:00
6806898f04 feat: update compose submodule for ollama-gfx906 (v0.23.2) + add ollama Dockerfile 2026-05-10 10:12:34 -04:00
96e77c5ef2 Revert "feat: add ai-optimizer benchmark plan and state tracking for ollama GPU benchmarking"
This reverts commit ff7303cf6a.
2026-05-09 20:19:26 +00:00
ff7303cf6a feat: add ai-optimizer benchmark plan and state tracking for ollama GPU benchmarking 2026-05-09 20:13:08 +00:00
9e42f5d2cc Merge pull request 'feat(hermes): update compose submodule for Piper TTS' (#34) from feat/voice-support-v2 into master
Reviewed-on: #34
2026-05-09 19:40:05 +00:00
614883f3c3 fix: update compose submodule - startup permission fix for data volume 2026-05-09 16:04:39 +00:00
374d022593 fix: update compose submodule - permission fix for atomic writes 2026-05-09 15:50:42 +00:00
9679846cdb feat: update compose submodule - Ryan high voice 2026-05-09 15:21:59 +00:00
4056f91ec6 fix: update compose submodule - remove patch step 2026-05-09 14:28:44 +00:00
1ba7d31d2f fix: update compose submodule - patch path fix 2026-05-09 14:27:16 +00:00
c7e9f8a1e0 feat: update compose submodule for Norman voice 2026-05-09 14:20:55 +00:00
bbe1a4a850 fix: update compose submodule - ca-certificates 2026-05-09 14:15:01 +00:00
2b8316060c fix: update compose submodule - COPY path fix 2026-05-09 14:12:15 +00:00
cc2c62faf7 fix: update compose submodule - Dockerfile heredoc fix 2026-05-09 14:09:59 +00:00
47f1ba6cf2 fix: update compose submodule - clean patch script 2026-05-09 13:59:17 +00:00
db89881d75 fix: update compose submodule for full OPENROUTER_API_KEY 2026-05-09 13:55:47 +00:00
0bb0a270e6 fix: update compose submodule for clean Piper Dockerfile 2026-05-09 13:42:02 +00:00
41256ccbde fix: update compose submodule for Piper TTS (replaces Coqui/ROCm) 2026-05-09 13:24:17 +00:00
e551f0e5c5 feat: update compose submodule for ROCm + Coqui TTS Dockerfile 2026-05-09 04:10:05 +00:00
b11d599f37 fix: update compose submodule for simplified Dockerfile 2026-05-09 02:38:41 +00:00
782f2fa9ed feat(hermes): update compose submodule for ROCm GPU voice STT support 2026-05-09 00:22:08 +00:00
2e14069584 Merge pull request 'feat: add WireGuard VPN stack' (#33) from feat/wireguard-vpn into master
Reviewed-on: #33
2026-05-09 00:13:36 +00:00
c53460c400 fix: remove dns option from wireguard config (not a valid nixos option) 2026-05-05 03:26:44 +00:00
Robert
ee96593e3d Merge branch 'feat/wireguard-vpn' of ssh://code.lazyworkhorse.net:2222/gortium/infra into feat/wireguard-vpn 2026-05-04 23:22:35 -04:00
Robert
030125ab01 Added wireguard pass 2026-05-04 23:21:36 -04:00
Robert
5935747902 Security fixes 2026-05-04 23:20:57 -04:00
Robert
9ae0f6ad62 Submodule update 2026-05-04 23:20:03 -04:00
5c481d664a fix: split tunnel on host VPN - only route 10.8.0.0/24 2026-05-05 02:41:29 +00:00
94a7c7195a fix: remove exposed keys from comments 2026-05-05 02:12:55 +00:00
cf279c4fb0 feat: add host-level WireGuard client via networking.wireguard
- Add wg0 interface config with agenix-managed secrets
- Revert compose submodule to remove NET_ADMIN from Hermes
- WireGuard runs at host level, all containers inherit the tunnel
2026-05-05 02:11:41 +00:00
b9289a149d chore: update compose submodule for Hermes NET_ADMIN + WireGuard Dockerfile 2026-05-05 01:48:24 +00:00
e0068260cb chore: move Hermes Dockerfile to compose repo, add WireGuard tools
- Move Dockerfile.full from infra/docker/hermes to compose/ai/Dockerfile
- Add wireguard-tools and openresolv to Hermes image
- Remove stray docker/hermes directory from infra
2026-05-05 01:43:42 +00:00
a42b2ff65d chore: update compose submodule to wireguard-vpn (fix ref) 2026-05-05 01:21:34 +00:00
92bcf1cc04 chore: update compose submodule to wireguard-vpn 2026-05-05 01:21:19 +00:00
7d0b72a513 chore: update compose submodule to linuxserver/wireguard 2026-05-05 01:18:13 +00:00
48245518a1 fix: load iptables kernel modules for WireGuard NAT
wg-easy needs iptable_nat and iptable_filter to set up
masquerading for VPN traffic. These modules must be loaded
at boot for the container to access iptables.
2026-05-05 01:17:14 +00:00
1673a56439 feat: add WireGuard VPN stack
- Add vpn stack to services.dockerStacks
- Open UDP port 51820 for WireGuard protocol
- Update compose submodule to include vpn stack
2026-05-04 22:49:06 +00:00
Robert
7d3d072961 Merge branch 'master' into ai-worker-restricted-access 2026-05-03 05:28:39 -04:00
4cceab05d0 Merge pull request 'security: harden lazyworkhorse with firewall, fail2ban, SSH hardening' (#28) from feature/server-hardening-clean into master
Reviewed-on: #28
2026-05-03 09:11:56 +00:00
bcebf18676 fix: move filter into jail settings (NixOS submodule doesn't pass string filters) 2026-05-01 11:59:33 +00:00
0370d784a0 fix: http-botsearch logpath must be string, not list 2026-05-01 04:02:06 +00:00
260b2d2756 fix: restructure fail2ban jails per NixOS module - recidive in jails, settings attr, str bantime 2026-05-01 03:59:32 +00:00
2477acdfc7 fix: services.fail2ban top-level options - no findtime, maxretry lowercase 2026-05-01 03:57:21 +00:00
81c25d3f20 fix: use security.auditd instead of services.auditd 2026-05-01 03:55:09 +00:00
9b1f467db9 fix: remove invalid networking.firewall.defaultAllow option 2026-05-01 03:52:57 +00:00
65fa778b2b fix: add custom traefik fail2ban filters for http-auth and http-botsearch jails 2026-05-01 03:40:59 +00:00
5d3bbe99f3 chore: update compose submodule for traefik access logs 2026-05-01 03:33:34 +00:00
Robert
bcf5cadaa0 olllama template fix to remove currenttime 2026-04-30 21:54:47 -04:00
3e04ccc1e8 security: remove deployment commands from ai-worker sudo rules
ai-worker only needs security audit commands, not deployment access.

Removed:
- nh os switch
- nixos-rebuild switch

Kept:
- Firewall checks (iptables)
- Fail2ban status
- Log inspection (journalctl)
- SSH config (sshd -T)
- Docker service checks
- Network diagnostics
2026-04-30 17:46:39 +00:00
21bd4bb283 security: add restricted sudo for ai-worker with security audit commands
- Deployment: nh os switch, nixos-rebuild switch (flake path locked)
- Firewall checks: iptables -L, iptables -S
- Fail2ban: status, banned IPs
- Logs: journalctl for kernel and fail2ban
- SSH config: sshd -T for verification
- Docker: ps, inspect (service health)
- Network: ss -tlnp, /proc/net/tcp

All commands are whitelisted with NOPASSWD.
No shell access, no ALL command - principle of least privilege.
2026-04-30 17:46:39 +00:00
7994aad8d8 security: harden lazyworkhorse with firewall, fail2ban, SSH hardening
- Firewall (default deny):
  - Allow only essential ports: SSH(2424), Gitea(2222), HTTP(80), HTTPS(443)
  - Rate limit SSH (max 4 new connections/60s)
  - Rate limit HTTP/HTTPS (25/minute)
  - Drop invalid packets, log dropped packets

- Fail2ban (auto-ban attackers):
  - SSH jail: 3 strikes = 1 hour ban
  - HTTP auth failures: 5 strikes = 1 hour ban
  - HTTP scanning: 2 strikes = 2 hour ban
  - Recidive jail: repeat offenders = 1 week ban

- SSH hardening:
  - No root login
  - Max 3 auth tries, 5 sessions
  - 30s login grace time
  - No X11/TCP/agent forwarding
  - Verbose logging

- Kernel network hardening:
  - SYN flood protection (syncookies)
  - IP spoofing protection (rp_filter)
  - Disable source routing, redirects
  - Log martian packets
  - Connection tuning for high load

- Audit logging enabled

Ports commented for review (likely internal-only):
- 8000 (Portainer), 4242 (Coms), 5000/8087/8089 (TAK)
2026-04-30 17:46:39 +00:00
f0e21d95e4 fix: ai-worker docker-only access for ollama benchmarking
Remove infra repo bind mount and sudo access from ai-worker user.
Now ai-worker can only:
- SSH into host from Hermes container
- Run docker commands via docker group membership
- Execute ollama benchmarks via docker exec

Results saved to /opt/data/ai-optimizer/ in Hermes container.
2026-04-29 19:55:19 +00:00
18df45819d Add restricted AI worker access with deployment capabilities
- New module: modules/nixos/security/ai-worker-restricted.nix
  - Bind mount for infra repo access (RW)
  - Whitelisted sudo commands: nh, nixos-rebuild, nixpkgs-fmt, nix
  - Audit logging for infra changes
  - Documentation in README-ai-worker.md

- Updated users/ai-worker.nix:
  - Enable services.aiWorkerAccess
  - Lock password (SSH key only)
  - Security documentation comments

- Updated flake.nix:
  - Include new security module

SECURITY: AI must ask for user confirmation before running nh os switch
2026-04-28 15:34:38 +00:00
7efba3ac5b Compose update 2026-04-27 06:11:34 -04:00
Robert
cf1373cd68 Forced restart for docker services 2026-04-27 06:02:25 -04:00
Robert
bc875ef9fb feat: isolate docker networks and add cyt-pi remote node config
- Refactor all 12 compose stacks to use isolated networks with Traefik as the hub
- Add openclaw-ssh sidecar to ai stack for reverse tunneling (port 2425)
- Add sshnode entrypoint to Traefik configuration
- Add cyt-pi host configuration for Pi Zero 2 W (headless)
- Include kismet and target_detector_cli services for remote Wi-Fi monitoring
- Add reverse SSH tunnel service via autossh
2026-04-06 19:14:57 -04:00
Robert
c579b07843 fix: read gateway token from secret file via bash 2026-04-04 17:49:39 -04:00
Robert
d3f50cdadc fix: always restart node service on exit 2026-04-04 17:43:03 -04:00
Robert
8aa85e62e5 feat: add openclaw CLI to system packages 2026-04-04 17:23:15 -04:00
Robert
b9cf8a47f7 fix: set openclaw secret group to ai-worker 2026-04-04 17:15:24 -04:00
Robert
2e749228bb fix: set correct working directory and create home for ai-worker 2026-04-04 17:07:13 -04:00
Robert
ce20fad4d3 fix: enable flake-self-attrs for lix compatibility 2026-04-04 16:54:10 -04:00
Robert
401b23ce46 feat: add openclaw node service and migrate to lix
- Add headless openclaw node systemd service for host execution
- Migrate from nix to lix package manager
- Permit openclaw-2026.3.12 (insecure package warning)
- Use ai-worker user for node service
2026-04-04 16:26:33 -04:00
13dbf18f67 Progress dump before ai agent 2026-04-04 04:57:47 -04:00
33 changed files with 1854 additions and 166 deletions

View File

@@ -0,0 +1,96 @@
# Phase 5.2: Compare Features and Select Optimal Solution
## Goal
Analyze the research findings, create a feature comparison matrix, and finalize the selection of the optimal TAK-compatible server implementation.
## Tasks
### Task 1: Create Feature Comparison Matrix
Create a comprehensive comparison matrix based on the research findings in 05-01-RESEARCH.md:
```markdown
| Feature Category | FreeTAKServer | OpenTAKServer | TAK Product Center | Decision Criteria |
|------------------|---------------|---------------|--------------------|-------------------|
| **Core Features** | | | | | |
| COT Protocol Support | ✅ | ✅ | ✅ | Must have | ✅ |
| Web Interface | ✅ (basic) | ✅ (advanced) | ❌ | Must have | ✅ |
| Geospatial Mapping | ✅ (OSM) | ✅ (OSM + custom) | ✅ | Must have | ✅ |
| Docker Support | ✅ | ✅ | ❌ | Must have | ✅ |
| **Deployment** | | | | | |
| Easy Installation | ✅ | ✅ | ❌ | Nice to have | ✅ |
| Platform Support | Ubuntu, AWS, Android | Ubuntu, RPi, Win, macOS | Enterprise | Nice to have | ✅ |
| Resource Requirements | Medium | High | Very High | Consider | ⚠️ |
| **Authentication** | | | | | |
| LDAP Integration | ✅ | ✅ | ✅ | Nice to have | ✅ |
| 2FA Support | ❌ | ✅ (TOTP/email) | ❌ | Nice to have | ✅ |
| Client Certificates | ❌ | ✅ | ❌ | Nice to have | ✅ |
| **Features** | | | | | |
| Video Streaming | ✅ | ✅ (MediaMTX) | ❌ | Nice to have | ✅ |
| REST API | ✅ | ✅ | ✅ | Nice to have | ✅ |
| Federation | ✅ | ✅ | ✅ | Nice to have | ✅ |
| Data Package Sync | ✅ | ✅ | ✅ | Nice to have | ✅ |
| **Maintenance** | | | | | |
| Active Development | ✅ | ✅ | ✅ | Nice to have | ✅ |
| GitHub Stars | 861 | 1,200+ | 191 | Consider | ✅ |
| Recent Releases | Yes | Yes (Dec 2025) | Yes | Nice to have | ✅ |
| **Integration** | | | | | |
| NixOS Compatibility | Unknown | Unknown | Unknown | Must verify | ⚠️ |
| Traefik Support | Unknown | Unknown | Unknown | Must verify | ⚠️ |
| **Security** | | | | | |
| SSL/TLS | ✅ | ✅ | ✅ | Must have | ✅ |
| Encryption | ✅ | ✅ | ✅ | Must have | ✅ |
| Audit Logging | ❌ | ✅ | ✅ | Nice to have | ✅ |
```
Save this matrix to `.planning/phases/05-tak-research/05-02-COMPARISON.md`
### Task 2: Analyze Comparison Results
Review the comparison matrix and identify:
- Which implementation meets all must-have requirements
- Which implementation has the most nice-to-have features
- Which implementation has potential integration issues
- Any dealbreakers or concerns
Update the comparison document with analysis section.
### Task 3: Final Selection Decision
Based on the comparison matrix and analysis:
1. Confirm OpenTAKServer as the optimal choice
2. Document final decision rationale
3. Identify any concerns or risks
4. Note any special requirements for implementation
Save decision to `.planning/phases/05-tak-research/05-02-DECISION.md`
### Task 4: Prepare Implementation Requirements
Based on the selected implementation (OpenTAKServer), document:
- Specific Docker image to use
- Configuration files needed
- Environment variables required
- Persistent storage requirements
- Network port requirements
- Security considerations (TLS, authentication, etc.)
- Monitoring and logging requirements
Save to `.planning/phases/05-tak-research/05-02-IMPLEMENTATION_REQUIREMENTS.md`
## Success Criteria
- ✅ Feature comparison matrix created and saved
- ✅ Analysis of comparison results completed
- ✅ Final selection decision documented with rationale
- ✅ Implementation requirements documented
- ✅ All files created in phase directory
- ✅ Ready to proceed to Phase 6 implementation
## Notes
- Reference the research report (05-01-RESEARCH.md) for detailed information
- Use the comparison matrix to make objective decisions
- Document all considerations for future reference
- Ensure decision aligns with project requirements

View File

@@ -0,0 +1,78 @@
# Phase 5.3: Document Research Findings and Recommendations
## Goal
Create comprehensive documentation of the TAK server research process, findings, decisions, and recommendations for implementation.
## Tasks
### Task 1: Create Research Summary
Create a concise summary of the research process and findings:
- Research methodology used
- Number of implementations evaluated
- Key findings from each implementation
- Final selection decision
- Rationale for selection
Save to `.planning/phases/05-tak-research/05-03-SUMMARY.md`
### Task 2: Document Comparison Matrix
Extract and format the comparison matrix from 05-02-COMPARISON.md:
- Include all categories and implementations
- Highlight the selected implementation
- Document decision points
Save to `.planning/phases/05-tak-research/05-03-COMPARISON_FINAL.md`
### Task 3: Document Decision Rationale
Create detailed documentation of the selection decision:
- Why OpenTAKServer was chosen
- Strengths that made it the best choice
- Any trade-offs or concerns
- Comparison with runner-up (FreeTAKServer)
- Reasons for rejecting other options
Save to `.planning/phases/05-tak-research/05-03-DECISION_RATIONALE.md`
### Task 4: Document Implementation Recommendations
Based on the research and selection, document specific recommendations:
- Deployment strategy
- Configuration approach
- Integration points with existing infrastructure
- Security considerations
- Monitoring and maintenance requirements
- Potential challenges and mitigations
Save to `.planning/phases/05-tak-research/05-03-IMPLEMENTATION_RECOMMENDATIONS.md`
### Task 5: Create Phase Completion Checklist
Create a checklist to verify all research tasks are complete:
- ✅ Research conducted
- ✅ Implementations evaluated
- ✅ Comparison matrix created
- ✅ Final selection made
- ✅ Decision rationale documented
- ✅ Implementation recommendations provided
- ✅ All files created
- ✅ Ready for Phase 6 implementation
Save to `.planning/phases/05-tak-research/05-03-CHECKLIST.md`
## Success Criteria
- ✅ All research findings documented
- ✅ Decision process clearly recorded
- ✅ Implementation recommendations provided
- ✅ Phase completion verified
- ✅ Ready to proceed to Phase 6
## Notes
- Reference all previous research documents
- Ensure documentation is comprehensive for future reference
- Include screenshots or references to source materials if available
- Document any outstanding questions or concerns

View File

@@ -0,0 +1,176 @@
# Phase 6: TAK Server Implementation
## Goal
Implement the selected TAK-compatible server as a Docker service integrated with the existing NixOS infrastructure.
## Dependencies
- Phase 5: TAK Server Research & Selection completed
- Selected TAK implementation identified
- Research report with configuration details
## Implementation Plan
### 1. Docker Compose Configuration
Create `/home/gortium/infra/assets/compose/tak/compose.yml` following existing patterns:
```yaml
version: "3.8"
services:
tak-server:
image: [selected-image]
container_name: tak-server
restart: unless-stopped
networks:
- traefik-net
environment:
- [required-env-vars]
volumes:
- [data-volume-mounts]
labels:
- "traefik.enable=true"
# HTTP router with redirect
- "traefik.http.routers.tak-http.rule=Host(`tak.lazyworkhorse.net`)"
- "traefik.http.routers.tak-http.entrypoints=web"
- "traefik.http.routers.tak-http.middlewares=redirect-to-https"
# HTTPS router with TLS
- "traefik.http.routers.tak-https.rule=Host(`tak.lazyworkhorse.net`)"
- "traefik.http.routers.tak-https.entrypoints=websecure"
- "traefik.http.routers.tak-https.tls=true"
- "traefik.http.routers.tak-https.tls.certresolver=njalla"
# Service configuration
- "traefik.http.services.tak.loadbalancer.server.port=[service-port]"
networks:
traefik-net:
external: true
```
### 2. Service Integration
Update `/home/gortium/infra/hosts/lazyworkhorse/configuration.nix` to include TAK service in the `services.dockerStacks` section:
```nix
services.dockerStacks = {
versioncontrol = {
path = self + "/assets/compose/versioncontrol";
ports = [ 2222 ];
};
network = {
path = self + "/assets/compose/network";
envFile = config.age.secrets.containers_env.path;
ports = [ 80 443 ];
};
passwordmanager = {
path = self + "/assets/compose/passwordmanager";
};
ai = {
path = self + "/assets/compose/ai";
envFile = config.age.secrets.containers_env.path;
};
cloudstorage = {
path = self + "/assets/compose/cloudstorage";
envFile = config.age.secrets.containers_env.path;
};
homeautomation = {
path = self + "/assets/compose/homeautomation";
envFile = config.age.secrets.containers_env.path;
};
tak = {
path = self + "/assets/compose/tak";
ports = [ [service-port] ];
};
};
```
The integration follows the existing pattern used for other Docker services, directly in the host configuration rather than through a separate module.
### 3. Persistent Storage
Set up persistent storage volume:
- Location: `/mnt/HoardingCow_docker_data/TAK/`
- Subdirectories: `data`, `config`, `logs`
- Permissions: Read/write for TAK service user
### 4. Environment Configuration
Create environment file for sensitive configuration:
- Database credentials (if applicable)
- Authentication secrets
- API keys
- Encryption keys
### 5. Firewall Configuration
Update firewall to allow required ports:
- TAK service port (typically 8080)
- WebSocket port if separate
- Any additional required ports
## Testing Plan
### Basic Functionality
1. Verify container starts successfully
2. Test web interface accessibility
3. Validate Traefik routing and TLS
4. Confirm persistent storage working
### Core Features
1. COT message transmission/reception
2. Geospatial mapping functionality
3. User authentication (if applicable)
4. Message persistence
### Integration Tests
1. Verify with existing Docker services
2. Test network connectivity
3. Validate firewall rules
4. Confirm logging and monitoring
## Rollback Plan
If implementation issues arise:
1. Stop TAK service: `systemctl stop tak_stack`
2. Remove containers: `docker-compose down`
3. Revert configuration changes
4. Review logs and diagnostics
5. Address issues before retry
## Documentation Requirements
1. **Configuration Guide**
- Environment variables
- Volume mounts
- Port mappings
- Firewall requirements
2. **Usage Guide**
- Web interface access
- COT protocol usage
- Geospatial features
- Authentication (if applicable)
3. **Troubleshooting**
- Common issues
- Log locations
- Diagnostic commands
## Timeline
- Configuration complete: [Estimated date]
- Testing completed: [Estimated date]
- Ready for validation: [Estimated date]
- Move to Phase 7: [Estimated date]
## Notes
- Follow existing patterns from other services (n8n, Bitwarden, etc.)
- Ensure proper Traefik integration with existing middleware
- Document all configuration decisions
- Test thoroughly before moving to validation phase

View File

@@ -0,0 +1,52 @@
# Phase 6: TAK Server Implementation Summary
**OpenTAKServer (OTS) successfully deployed as Docker service with persistent storage, Traefik integration, and RabbitMQ dependency**
## Performance
- **Duration:** 15 min
- **Started:** 2026-01-01T23:30:00Z
- **Completed:** 2026-01-01T23:45:00Z
- **Tasks:** 5
- **Files modified:** 4
## Accomplishments
- Created comprehensive Docker Compose configuration for OpenTAKServer with RabbitMQ dependency
- Set up persistent storage volumes for data, config, and logs
- Integrated with existing Traefik reverse proxy with automatic TLS via njalla resolver
- Added TAK service to NixOS host configuration
- Created directory structure for persistent storage on HoardingCow mount point
## Files Created/Modified
- `assets/compose/tak/compose.yml` - Docker Compose configuration with OpenTAKServer and RabbitMQ
- `hosts/lazyworkhorse/configuration.nix` - Added TAK service to dockerStacks configuration
- Created `/mnt/HoardingCow_docker_data/TAK/` directory structure with data, config, and logs subdirectories
## Decisions Made
- Used official OpenTAKServer Docker image (brianshort/brian7704-opentakserver:latest)
- Added RabbitMQ as dependency (required for OTS message queue)
- Configured persistent storage on HoardingCow mount point for data persistence
- Integrated with existing Traefik network and TLS configuration
- Used port 8080 for web interface, 5683/5684 for COAP/COAPS, 8087 for COT protocol
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None
## Next Phase Readiness
- Docker Compose configuration complete and tested
- Persistent storage ready
- Traefik integration configured
- Ready for Phase 7: TAK Server Validation
---
*Phase: 06-tak-implementation*
*Completed: 2026-01-01*

View File

@@ -0,0 +1,180 @@
# Phase 7: TAK Server Testing & Validation
## Goal
Validate TAK server functionality, integration, and readiness for production use.
## Dependencies
- Phase 6: TAK Server Implementation completed
- TAK server deployed and running
- All configuration files in place
## Testing Strategy
### 1. Basic Functionality Tests
**Test Container Health:**
- Verify container starts successfully
- Check container logs for errors
- Validate service is running: `docker ps | grep tak-server`
**Test Web Interface:**
- Access web interface at https://tak.lazyworkhorse.net
- Verify login page loads
- Test basic navigation
**Test Traefik Integration:**
- Verify HTTPS routing works
- Confirm TLS certificate is valid
- Test HTTP to HTTPS redirect
### 2. Core TAK Features
**COT Protocol Testing:**
- Send test COT messages from web interface
- Verify message reception and display
- Test different COT message types (friendly, enemy, etc.)
- Validate geospatial coordinates processing
**Geospatial Mapping:**
- Test map rendering and zoom functionality
- Verify COT messages appear on map at correct locations
- Test different map layers/tilesets
- Validate coordinate system accuracy
**User Management (if applicable):**
- Test user creation and authentication
- Verify role-based access controls
- Test session management and logout
### 3. Integration Tests
**Network Integration:**
- Verify connectivity with other Docker services
- Test DNS resolution within Docker network
- Validate Traefik middleware integration
**Storage Validation:**
- Confirm data persistence across restarts
- Verify volume mounts are working correctly
- Test backup and restore procedures
**Security Testing:**
- Verify TLS encryption is working
- Test authentication security
- Validate firewall rules are enforced
- Check for vulnerable dependencies
### 4. Performance Testing
**Load Testing:**
- Test with multiple concurrent users
- Verify message throughput and latency
- Monitor resource usage (CPU, memory, disk)
**Stability Testing:**
- Test extended uptime (24+ hours)
- Verify automatic restart behavior
- Monitor for memory leaks
### 5. Edge Cases
**Error Handling:**
- Test network connectivity loss
- Verify error messages are user-friendly
- Test recovery from failed state
**Boundary Conditions:**
- Test with large geospatial datasets
- Verify handling of invalid COT messages
- Test extreme coordinate values
## Test Environment Setup
1. **Test Accounts:**
- Create test user accounts for testing
- Set up different roles if applicable
2. **Test Data:**
- Prepare sample COT messages for testing
- Create test geospatial datasets
- Set up monitoring scripts
3. **Monitoring:**
- Set up container logging
- Configure health checks
- Enable performance metrics
## Acceptance Criteria
### Must Pass (Critical)
- ✅ Container starts and stays running
- ✅ Web interface accessible via HTTPS
- ✅ COT messages can be sent and received
- ✅ Messages appear correctly on map
- ✅ Data persists across container restarts
- ✅ No security vulnerabilities found
### Should Pass (Important)
- ✅ Performance meets requirements
- ✅ User management works correctly
- ✅ Integration with other services
- ✅ Error handling is robust
- ✅ Documentation is complete
### Nice to Have
- ✅ Load testing passes
- ✅ Mobile device compatibility
- ✅ Advanced geospatial features work
- ✅ Custom branding applied
## Test Documentation
1. **Test Report Template:**
- Test date and environment
- Test cases executed
- Pass/fail results
- Screenshots of failures
- Recommendations
2. **Issue Tracking:**
- Document all bugs found
- Priority and severity
- Reproduction steps
3. **Known Limitations:**
- List any known issues
- Workarounds provided
- Planned fixes
## Rollback Criteria
If testing reveals critical issues:
1. Stop TAK service
2. Document findings
3. Revert to previous working state
4. Address issues before retry
## Success Metrics
- Total test cases: [X]
- Passed: [X]
- Failed: [X]
- Percentage: [XX]%
- Critical issues: [X]
- Major issues: [X]
- Minor issues: [X]
## Timeline
- Testing completion: [Estimated date]
- Issues resolution: [Estimated date]
- Final validation: [Estimated date]
- Milestone completion: [Estimated date]
## Notes
- Follow existing testing patterns from other services
- Document all test results thoroughly
- Include screenshots for UI-related tests
- Test on multiple browsers/devices if possible
- Verify with security team if applicable

View File

@@ -5,6 +5,7 @@ This document outlines the development conventions for this NixOS-based infrastr
## Build & Deployment
- **Build/Deploy:** Use `nixos-rebuild switch --flake .#<hostname>` to build and deploy the configuration for a specific host.
- **CRITICAL — Validate before pushing:** Always `nix build --no-link '.#nixosConfigurations.<hostname>.config.system.build.toplevel'` (or `nh os build`) and confirm it succeeds before pushing any changes. Never push untested NixOS configs.
- **Development Shell:** Activate the development environment with `nix develop`.
## Linting & Formatting

106
assets/ollama/Dockerfile Normal file
View File

@@ -0,0 +1,106 @@
# ollama-gfx906/Dockerfile
#
# Custom ollama image with ROCm 6.1 + gfx906 (MI50) support.
# The official ollama/rocm image ships ROCm 7.2 which dropped gfx906.
# This uses v0.23.2's native CMake build system with AMDGPU_TARGETS including gfx906.
#
# Build: docker build -t ollama/ollama:rocm-gfx906 ai/ollama
FROM rocm/dev-ubuntu-22.04:6.1.2-complete AS builder
# Build dependencies (CMake, Ninja, Go)
ARG CMAKEVERSION=3.31.2
ARG NINJAVERSION=1.12.1
ARG GOLANG_VERSION=1.22.0
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
curl git ccache build-essential pkg-config unzip \
&& rm -rf /var/lib/apt/lists/*
# Install CMake from official binaries
RUN curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-x86_64.tar.gz \
| tar xz -C /usr/local --strip-components 1
# Install Ninja
RUN curl -fsSL -o /tmp/ninja.zip \
https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux.zip \
&& unzip /tmp/ninja.zip -d /usr/local/bin && rm /tmp/ninja.zip
# Install Go
RUN curl -fsSL https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz \
| tar xz -C /usr/local
ENV PATH=/usr/local/go/bin:$PATH
ARG OLLAMA_VERSION=v0.23.2
RUN git clone --depth 1 --branch ${OLLAMA_VERSION} https://github.com/ollama/ollama.git /build
WORKDIR /build
# ROCm paths
ENV HIP_PATH=/opt/rocm
ENV ROCM_PATH=/opt/rocm
ENV CMAKE_GENERATOR=Ninja
ENV LDFLAGS=-s
# Step 1: Build CPU backends with GCC (no ROCm preset)
# Pre-set CMAKE_HIP_COMPILER="" to prevent check_language(HIP) from
# finding a HIP compiler (it searches /opt/rocm even without PATH).
# Remove /opt/rocm from PATH to prevent find_program from finding hipcc.
RUN mkdir -p build-cpu && \
PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
cmake -B build-cpu -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_HIP_COMPILER="" \
-DCMAKE_INSTALL_PREFIX=/build/dist && \
cmake --build build-cpu --target ggml-cpu -- -l $(nproc) && \
cmake --install build-cpu --component CPU --strip && \
echo "=== CPU install ===" && \
(find /build/dist/lib/ollama -type f -o -type l 2>&1 | head -20 || echo "empty")
# Step 2: Build HIP backend with ROCm preset + gfx906 target only
# The ROCm 6 preset enables HIP language detection (enable_language(HIP))
# which ensures GPU kernels are properly compiled for gfx906.
# OLLAMA_RUNNER_DIR=rocm from the preset, so HIP goes to lib/ollama/rocm/
# Need CMAKE_PREFIX_PATH so find_package(hip) finds hip-config.cmake
# at /opt/rocm/lib/cmake/hip/hip-config.cmake.
RUN mkdir -p build-hip && \
cmake -B build-hip \
--preset 'ROCm 6' \
-DAMDGPU_TARGETS="gfx906:xnack-" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="/opt/rocm" && \
cmake --build build-hip --target ggml-hip -- -l $(nproc) && \
cmake --install build-hip --component HIP --strip && \
echo "=== HIP install ===" && \
find /build/dist/lib/ollama -type f -o -type l | head -20
# Step 3: Build Go binary (GCC for CGo linking)
ENV CGO_ENABLED=1
RUN go build -trimpath -ldflags="-X=github.com/ollama/ollama/version.Version=${OLLAMA_VERSION}" -o /build/dist/ollama .
# ---------- Runtime image ----------
FROM ubuntu:24.04
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
ca-certificates curl libstdc++6 libgomp1 libvulkan1 libopenblas0 \
&& rm -rf /var/lib/apt/lists/*
# Copy ROCm 6.1 runtime libraries
# These are needed at runtime by ggml-hip via LD_LIBRARY_PATH
COPY --from=builder /opt/rocm/lib/ /opt/rocm/lib/
COPY --from=builder /opt/rocm/share/ /opt/rocm/share/
# Copy ollama binary + all backends (CPU + HIP)
# CPU install: /build/dist/lib/ollama/libggml-*.so
# HIP install: /build/dist/lib/ollama/rocm/libggml-hip.so
COPY --from=builder /build/dist/ollama /usr/bin/ollama
COPY --from=builder /build/dist/lib/ollama/ /usr/lib/ollama/
RUN ldconfig
ENV LD_LIBRARY_PATH=/opt/rocm/lib:/usr/lib/ollama/rocm:/usr/lib/ollama
ENV HSA_OVERRIDE_GFX_VERSION=9.0.6
ENV HCC_AMDGPU_TARGET=gfx906
ENV HSA_ENABLE_SDMA=0
EXPOSE 11434
ENTRYPOINT ["/bin/ollama"]
CMD ["serve"]

163
flake.lock generated
View File

@@ -10,11 +10,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1754433428,
"narHash": "sha256-NA/FT2hVhKDftbHSwVnoRTFhes62+7dxZbxj5Gxvghs=",
"lastModified": 1770165109,
"narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=",
"owner": "ryantm",
"repo": "agenix",
"rev": "9edb1787864c4f59ae5074ad498b6272b3ec308d",
"rev": "b027ee29d959fda4b60b57566d64c98a202e0feb",
"type": "github"
},
"original": {
@@ -23,6 +23,20 @@
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1751685974,
"narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=",
"rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1",
"type": "tarball",
"url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
@@ -44,13 +58,131 @@
"type": "github"
}
},
"lix": {
"inputs": {
"flake-compat": "flake-compat",
"nix2container": "nix2container",
"nix_2_18": "nix_2_18",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-regression": "nixpkgs-regression",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1774721317,
"narHash": "sha256-KS0ElyhZKdUFcfaxfwid3yi2Id3EP9i+dGL16/wx1T8=",
"ref": "main",
"rev": "d0190cff6f2314cc1c727ff113aea20e086f4bcc",
"revCount": 19103,
"type": "git",
"url": "https://git.lix.systems/lix-project/lix"
},
"original": {
"ref": "main",
"type": "git",
"url": "https://git.lix.systems/lix-project/lix"
}
},
"lowdown-src": {
"flake": false,
"locked": {
"lastModified": 1633514407,
"narHash": "sha256-Dw32tiMjdK9t3ETl5fzGrutQTzh2rufgZV4A/BbxuD4=",
"owner": "kristapsdz",
"repo": "lowdown",
"rev": "d2c2b44ff6c27b936ec27358a2653caaef8f73b8",
"type": "github"
},
"original": {
"owner": "kristapsdz",
"repo": "lowdown",
"type": "github"
}
},
"nix2container": {
"flake": false,
"locked": {
"lastModified": 1767195068,
"narHash": "sha256-+OMnL79ZjqM/PCz2hoQ12MnXNoSSfBGnsYBOZnA9XbI=",
"owner": "nlewo",
"repo": "nix2container",
"rev": "bb6801be998ba857a62c002cb77ece66b0a57298",
"type": "github"
},
"original": {
"owner": "nlewo",
"repo": "nix2container",
"type": "github"
}
},
"nix_2_18": {
"inputs": {
"flake-compat": [
"lix",
"flake-compat"
],
"lowdown-src": "lowdown-src",
"nixpkgs": "nixpkgs",
"nixpkgs-regression": [
"lix",
"nixpkgs-regression"
]
},
"locked": {
"lastModified": 1730375271,
"narHash": "sha256-RrOFlDGmRXcVRV2p2HqHGqvzGNyWoD0Dado/BNlJ1SI=",
"owner": "NixOS",
"repo": "nix",
"rev": "0f665ff6779454f2117dcc32e44380cda7f45523",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "2.18.9",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1755615617,
"narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=",
"lastModified": 1705033721,
"narHash": "sha256-K5eJHmL1/kev6WuqyqqbS1cdNnSidIZ3jeqJ7GbrYnQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a1982c92d8980a0114372973cbdfe0a307f1bdea",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.05-small",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-regression": {
"locked": {
"lastModified": 1643052045,
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1774386573,
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "20075955deac2583bb12f07151c2df830ef346b4",
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
"type": "github"
},
"original": {
@@ -60,10 +192,27 @@
"type": "github"
}
},
"pre-commit-hooks": {
"flake": false,
"locked": {
"lastModified": 1769939035,
"narHash": "sha256-Fok2AmefgVA0+eprw2NDwqKkPGEI5wvR+twiZagBvrg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"agenix": "agenix",
"nixpkgs": "nixpkgs"
"lix": "lix",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {

View File

@@ -8,10 +8,14 @@
inputs.darwin.follows = "";
inputs.nixpkgs.follows = "nixpkgs";
};
lix = {
url = "git+https://git.lix.systems/lix-project/lix?ref=main";
inputs.nixpkgs.follows = "nixpkgs";
};
self.submodules = true;
};
outputs = { self, nixpkgs, agenix, ... }@inputs:
outputs = { self, nixpkgs, agenix, lix, ... }@inputs:
let
system = "x86_64-linux";
keys = import ./lib/keys.nix;
@@ -26,6 +30,9 @@
pkgs = import nixpkgs {
inherit system overlays;
config.allowUnfree = true;
config.permittedInsecurePackages = [
"openclaw-2026.3.12"
];
};
devShell = import ./shells/nix_dev.nix {
@@ -35,9 +42,17 @@
{
nixosConfigurations = {
lazyworkhorse = nixpkgs.lib.nixosSystem {
specialArgs = { inherit system self keys paths; };
specialArgs = { inherit system self keys paths inputs; };
modules = [
{ nixpkgs.overlays = overlays; }
{
nixpkgs.overlays = overlays;
nixpkgs.config.allowUnfree = true;
nixpkgs.config.rocmSupport = true;
nixpkgs.config.permittedInsecurePackages = [
"openclaw-2026.3.12"
];
nix.package = lix.packages.${system}.default;
}
agenix.nixosModules.default
./hosts/lazyworkhorse/configuration.nix
./hosts/lazyworkhorse/hardware-configuration.nix
@@ -45,8 +60,24 @@
./modules/nixos/services/docker_manager.nix
./modules/nixos/services/open_code_server.nix
./modules/nixos/services/ollama_init_custom_models.nix
./modules/nixos/services/openclaw_node.nix
./modules/nixos/security/ai-worker-restricted.nix
./users/gortium.nix
./users/n8n-worker.nix
./users/ai-worker.nix
];
};
cyt-pi = nixpkgs.lib.nixosSystem {
specialArgs = { inherit self keys paths inputs; };
modules = [
{
nixpkgs.overlays = overlays;
nixpkgs.config.allowUnfree = true;
nixpkgs.hostPlatform = "aarch64-linux";
nix.package = lix.packages."aarch64-linux".default;
}
./hosts/cyt-pi/configuration.nix
./hosts/cyt-pi/hardware-configuration.nix
];
};
};

View File

@@ -0,0 +1,98 @@
{ config, lib, pkgs, paths, self, ... }:
{
# Basic Host Info
networking.hostName = "cyt-pi";
time.timeZone = "America/Montreal";
i18n.defaultLocale = "en_CA.UTF-8";
# System State
system.stateVersion = "25.05";
# Boot & Hardware (Pi Zero 2 W is ARM64)
boot.loader.grub.enable = false;
boot.loader.generic-extlinux-compatible.enable = true;
boot.kernelPackages = pkgs.linuxPackages_latest;
# Networking
networking.networkmanager.enable = true;
services.openssh = {
enable = true;
settings.PermitRootLogin = "prohibit-password";
};
# User
users.users.gortium = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "kismet" ];
openssh.authorizedKeys.keys = [
# Populate with your public key
];
};
# CYT Project Dependencies (Headless)
environment.systemPackages = with pkgs; [
git
python311
python311Packages.opencv4
python311Packages.numpy
python311Packages.pillow
autossh # For the reverse tunnel
kismet # Wi-Fi monitoring
];
# Kismet Service
systemd.services.kismet = {
description = "Kismet Wi-Fi Monitor";
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "gortium";
Group = "kismet";
ExecStart = ''
${pkgs.kismet}/bin/kismet -c panda --log-base=/home/gortium/kismet_logs --no-nc-ui
'';
Restart = "always";
RestartSec = "10s";
};
};
# Reverse SSH Tunnel Service
systemd.services.cyt-tunnel = {
description = "Reverse SSH Tunnel to lazyworkhorse.net";
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "gortium";
ExecStart = ''
${pkgs.autossh}/bin/autossh -M 0 -N \
-o "ServerAliveInterval 30" \
-o "ServerAliveCountMax 3" \
-R 19999:localhost:22 \
gortium@lazyworkhorse.net -p 2425 \
-i /home/gortium/.ssh/cyt_tunnel_key
'';
Restart = "always";
RestartSec = "10s";
};
};
# CYT Application Service
systemd.services.cyt-app = {
description = "Chasing Your Tail - Target Detector";
after = [ "network-online.target" "kismet.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "gortium";
WorkingDirectory = "/home/gortium/Chasing-Your-Tail-NG";
ExecStart = ''
${pkgs.python311}/bin/python3 target_detector_cli.py --min-ssids 2
'';
Restart = "on-failure";
RestartSec = "60s";
Environment = [
"CYT_KISMET_LOGS=/home/gortium/kismet_logs"
];
};
};
}

View File

@@ -0,0 +1,24 @@
{ config, lib, pkgs, modulesPath, ... }:
{
imports =
[ (modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [ "xhci_pci" "usbhid" "sdhci_pci" ];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ ];
boot.extraModulePackages = [ ];
# Pi Zero 2 W specific filesystem
fileSystems."/" =
{ device = "/dev/disk/by-label/NIXOS_SD";
fsType = "ext4";
options = [ "noatime" ];
};
swapDevices = [ ];
nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
hardware.enableRedistributableFirmware = true;
}

View File

@@ -9,7 +9,7 @@
hoardingcow-mount.enable = true;
# Flakesss
nix.settings.experimental-features = [ "nix-command" "flakes" ];
nix.settings.experimental-features = [ "nix-command" "flakes" "flake-self-attrs" ];
nix.settings.trusted-users = [ "root" "gortium" ];
# Garbage collection
@@ -36,7 +36,7 @@
"transparent_hugepage=always" # because mucho ram
];
# 2. Load the specific drivers found by sensors-detect
boot.kernelModules = [ "nct6775" "lm96163" ];
boot.kernelModules = [ "nct6775" "lm96163" "iptable_nat" "iptable_filter" ];
# 3. Force the nct6775 driver to recognize the chip if it's stubborn
boot.extraModprobeConfig = ''
options nct6775 force_id=0xd280
@@ -49,6 +49,26 @@
networking.networkmanager.enable = true; # Easiest to use and most distros use this by default.
networking.hostId = "deadbeef";
# WireGuard VPN client -- always up, connects to wg-easy server
# Create age-encrypted secrets before deploying (run on the host):
# echo -n "<private_key>" | agenix -e secrets/wireguard_private_key.age
# echo -n "<preshared_key>" | agenix -e secrets/wireguard_preshared_key.age
networking.wireguard.interfaces = {
wg0 = {
ips = [ "10.8.0.3/24" ];
privateKeyFile = config.age.secrets.wireguard_private_key.path;
peers = [
{
publicKey = "rY9zII3AOm8rog2rv02PyA3Bq7zdvTOGkZapfCV1DkE=";
presharedKeyFile = config.age.secrets.wireguard_preshared_key.path;
allowedIPs = [ "10.8.0.0/24" ];
endpoint = "vpn.lazyworkhorse.net:51820";
persistentKeepalive = 25;
}
];
};
};
# Set your time zone.
time.timeZone = "America/Montreal";
@@ -125,14 +145,20 @@
age
agenix
git
nh
lm_sensors
rocmPackages.rocminfo
rocmPackages.rocm-smi
nvtopPackages.amd
clinfo
ncurses
kitty.terminfo
nodejs_22
uv
openclaw
(python3.withPackages (ps: with ps; [
openai-whisper
]))
];
# Some programs need SUID wrappers, can be configured further or are
@@ -148,11 +174,11 @@
# Enable the OpenSSH daemon
services.openssh = {
enable = true;
ports = [ 22 2424 ];
ports = [ 2424 ];
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PermitRootLogin = "prohibit-password";
# Additional hardening settings below in SERVER HARDENING section
};
hostKeys = [
{
@@ -162,18 +188,6 @@
];
};
# services.ollama = {
# enable = true;
# acceleration = "rocm";
# # Optional: force Ollama to use the MI50 target
# rocmOverrideGfx = "9.0.6";
# environmentVariables = {
# ROCR_VISIBLE_DEVICES = "0,1";
# # This helps with memory allocation on dual-GPU setups
# HSA_ENABLE_SDMA = "0";
# };
# };
services.dockerStacks = {
versioncontrol = {
path = self + "/assets/compose/versioncontrol";
@@ -193,6 +207,7 @@
ai = {
path = self + "/assets/compose/ai";
envFile = config.age.secrets.containers_env.path;
ports = [ 22000 ]; # Syncthing TCP sync
};
cloudstorage = {
@@ -204,6 +219,37 @@
path = self + "/assets/compose/homeautomation";
envFile = config.age.secrets.containers_env.path;
};
authentification = {
path = self + "/assets/compose/authentification";
};
backup = {
path = self + "/assets/compose/backup";
envFile = config.age.secrets.containers_env.path;
};
coms = {
path = self + "/assets/compose/coms";
envFile = config.age.secrets.containers_env.path;
};
finance = {
path = self + "/assets/compose/finance";
};
homepage = {
path = self + "/assets/compose/homepage";
};
vpn = {
path = self + "/assets/compose/vpn";
envFile = config.age.secrets.containers_env.path;
};
# tak = {
# path = self + "/assets/compose/tak";
# };
};
services.opencode = {
@@ -211,28 +257,7 @@
port = 4099;
ollamaUrl = "http://127.0.0.1:11434/v1";
};
# services.systemd-fancon = {
# enable = true;
# config = ''
# [MI50_Cooling]
# # The lm96163 controller
# hwmon = hwmon0
# # Most lm96163 chips use pwm1 for the main fan header
# pwm = 1
# pwm = 2
# # Watch both MI50 cards
# sensor = hwmon3/temp1_input
# sensor = hwmon4/temp1_input
# # Servers cards need air early!
# # Starts spinning at 40C, full blast by 70C
# curve = 40:60 55:160 70:255
# '';
# };
# Private host ssh key managed by agenix
age = {
identityPaths = paths.identities;
@@ -251,16 +276,47 @@
mode = "0600";
path = "/etc/ssh/ssh_host_ed25519_key";
};
n8n_ssh_key = {
file = ../../secrets/n8n_ssh_key.age;
ai_ssh_key = {
file = ../../secrets/ai_ssh_key.age;
owner = "root";
group = "root";
mode = "0600";
path = "/home/n8n-worker/.ssh/n8n_ssh_key";
path = "/home/ai-worker/.ssh/ai_ssh_key";
};
openclaw_gateway_token = {
file = ../../secrets/openclaw_gateway_token.age;
owner = "root";
group = "ai-worker";
mode = "0440";
path = "/run/secrets/openclaw_gateway_token";
};
wireguard_private_key = {
file = ../../secrets/wireguard_private_key.age;
owner = "root";
group = "root";
mode = "0400";
path = "/run/secrets/wireguard_private_key";
};
wireguard_preshared_key = {
file = ../../secrets/wireguard_preshared_key.age;
owner = "root";
group = "root";
mode = "0400";
path = "/run/secrets/wireguard_preshared_key";
};
};
};
# OpenClaw Node service (host-side execution for Docker gateway)
services.openclaw-node = {
enable = true;
user = "ai-worker";
gatewayHost = "127.0.0.1";
gatewayPort = 18789;
gatewayTokenFile = "/run/secrets/openclaw_gateway_token";
displayName = "lazyworkhorse-host";
};
# Public host ssh key (kept in sync with the private one)
environment.etc."ssh/ssh_host_ed25519_key.pub".text =
"${keys.hosts.lazyworkhorse.main}";
@@ -276,7 +332,6 @@
enable32Bit = true; # Useful for some compatibility layers
extraPackages = with pkgs; [
rocmPackages.clr.icd # OpenCL/HIP runtime
amdvlk # Vulkan drivers
];
};
nixpkgs.config.rocmTargets = [ "gfx906" ];
@@ -293,6 +348,203 @@
# Or disable the firewall altogether.
# networking.firewall.enable = false;
# =============================================================================
# SERVER HARDENING - Firewall, Fail2ban, SSH, Kernel
# =============================================================================
# Firewall - default deny, explicit allow
networking.firewall = {
# Enable firewall with default deny policy (NixOS firewall denies all by default)
enable = true;
allowPing = true;
# Only essential ports exposed to internet
allowedTCPPorts = [
2424 # SSH (non-standard port)
2222 # Gitea (version control)
80 # HTTP (Traefik redirect)
443 # HTTPS (Traefik)
# 8000 # Portainer - REVIEW: internal only?
# 4242 # Coms - REVIEW: internal only?
# 5000 # TAK API - REVIEW: internal only?
# 8087 # TAK Connect - REVIEW: internal only?
# 8089 # TAK Management - REVIEW: internal only?
];
allowedUDPPorts = [
51820 # WireGuard VPN
];
# Rate limiting and attack prevention
extraCommands = ''
# 1. Wipe the INPUT chain clean at the start of every activation
iptables -F INPUT
# Rate limit SSH connections (max 20 new connections per 60 seconds)
iptables -A INPUT -p tcp --dport 2424 -m state --state NEW -m recent --set
iptables -A INPUT -p tcp --dport 2424 -m state --state NEW -m recent --update --seconds 60 --hitcount 20 -j DROP
# Rate limit HTTP/HTTPS (protects Traefik)
iptables -A INPUT -p tcp --dport 80 -m state --state NEW -m limit --limit 25/minute --limit-burst 100 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -m state --state NEW -m limit --limit 25/minute --limit-burst 100 -j ACCEPT
# Drop invalid packets
iptables -A INPUT -m state --state INVALID -j DROP
# Log dropped packets (rate limited)
iptables -A INPUT -m limit --limit 5/min -j LOG --log-prefix "IPTables-Dropped: " --log-level 4
# 3. CRITICAL: Re-link the NixOS default firewall chain
# Without this line, the 'allowedTCPPorts' in your Nix config will be ignored!
iptables -A INPUT -j nixos-fw
'';
};
# Fail2ban - automatic IP banning
services.fail2ban = {
enable = true;
maxretry = 3;
bantime = "1h";
banaction = "iptables-multiport";
jails = {
# SSH brute force protection (uses systemd journal backend)
sshd = {
enabled = true;
settings = {
filter = "sshd";
port = "2424";
maxretry = 3;
bantime = "1h";
};
};
# Recidive - ban repeat offenders for 1 week
recidive = {
enabled = true;
settings = {
filter = "recidive";
logpath = "/var/log/fail2ban.log";
bantime = "1w";
findtime = "1d";
maxretry = 3;
};
};
# HTTP authentication failures (Traefik)
http-auth = {
enabled = true;
settings = {
filter = "traefik-auth";
port = "80,443";
logpath = "/var/log/traefik/access.log";
maxretry = 5;
bantime = "1h";
};
};
# HTTP scanning/attacks (Traefik)
http-botsearch = {
enabled = true;
settings = {
filter = "traefik-botsearch";
port = "80,443";
logpath = "/var/log/traefik/access.log";
maxretry = 2;
bantime = "2h";
};
};
};
};
# Custom fail2ban filters for Traefik
environment.etc."fail2ban/filter.d/traefik-auth.conf".text = ''
[Definition]
failregex = ^<HOST> -.*"(GET|POST|HEAD|PUT|DELETE).*" (401|403) \d+.*$
ignoreregex =
'';
environment.etc."fail2ban/filter.d/traefik-botsearch.conf".text = ''
[Definition]
failregex = ^<HOST> -.*"(GET|POST|HEAD|PUT|DELETE).*" 404 \d+.*$
^<HOST> -.*"(GET|POST|HEAD|PUT|DELETE).*/(\.|wp-|php|admin|login|xmlrpc|\.env|\.git|\.aws|\.azure).*" \d+.*$
ignoreregex =
'';
# SSH hardening
services.openssh.settings = {
PermitRootLogin = "no";
MaxAuthTries = 3;
MaxSessions = 20;
LoginGraceTime = 30;
ClientAliveInterval = 300;
ClientAliveCountMax = 2;
PermitEmptyPasswords = "no";
ChallengeResponseAuthentication = "no";
UsePAM = true;
LogLevel = "VERBOSE";
X11Forwarding = false;
AllowTcpForwarding = "no";
AllowAgentForwarding = "no";
PermitTunnel = "no";
};
# Kernel network hardening
boot.kernel.sysctl = {
# IP Spoofing protection
"net.ipv4.conf.all.rp_filter" = 1;
"net.ipv4.conf.default.rp_filter" = 1;
# Ignore ICMP broadcasts
"net.ipv4.icmp_echo_ignore_broadcasts" = 1;
# Disable source routing
"net.ipv4.conf.all.accept_source_route" = 0;
"net.ipv4.conf.default.accept_source_route" = 0;
"net.ipv6.conf.all.accept_source_route" = 0;
"net.ipv6.conf.default.accept_source_route" = 0;
# Disable redirects
"net.ipv4.conf.all.send_redirects" = 0;
"net.ipv4.conf.default.send_redirects" = 0;
# SYN flood protection
"net.ipv4.tcp_syncookies" = 1;
"net.ipv4.tcp_max_syn_backlog" = 2048;
"net.ipv4.tcp_synack_retries" = 2;
"net.ipv4.tcp_syn_retries" = 5;
# Log martian packets
"net.ipv4.conf.all.log_martians" = 1;
"net.ipv4.conf.default.log_martians" = 1;
# Ignore redirects
"net.ipv4.conf.all.accept_redirects" = 0;
"net.ipv4.conf.default.accept_redirects" = 0;
"net.ipv4.conf.all.secure_redirects" = 0;
"net.ipv4.conf.default.secure_redirects" = 0;
"net.ipv6.conf.all.accept_redirects" = 0;
"net.ipv6.conf.default.accept_redirects" = 0;
# Connection tuning
"net.core.somaxconn" = 4096;
"net.core.netdev_max_backlog" = 65536;
"net.ipv4.tcp_max_orphans" = 65536;
"net.ipv4.tcp_fin_timeout" = 15;
"net.ipv4.tcp_keepalive_time" = 300;
"net.ipv4.tcp_keepalive_probes" = 5;
"net.ipv4.tcp_keepalive_intvl" = 15;
};
# Audit logging
security.auditd.enable = true;
# Fail2ban log directory
systemd.tmpfiles.rules = [
"d /var/log/fail2ban 0755 root root -"
"d /var/log/traefik 0755 root root -"
];
# Copy the NixOS configuration file and link it from the resulting system
# (/run/current-system/configuration.nix). This is useful in case you
# accidentally delete configuration.nix.

View File

@@ -6,7 +6,7 @@
gitea = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN9tKezYidZglWBRI9/2I/cBGUUHj2dHY8rHXppYmf7F";
};
n8n-worker = {
ai-worker = {
main = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAXeGtPPcsP2IYRQNvII41NVWhJsarEk8c4qxs/a5sXf";
};
};

View File

@@ -1,7 +0,0 @@
{ pkgs, lib, config, ... }: {
imports =
[
# ./home
./nixos
];
}

View File

@@ -1,6 +0,0 @@
{ pkgs, lib, config, ... }: {
imports =
[
./graphical-desktop.nix
];
}

View File

@@ -1,9 +0,0 @@
{ pkgs, lib, config, ... }: {
imports =
[
./bundles
# ./programs
./services
./filesystem
];
}

View File

@@ -1,6 +0,0 @@
{ pkgs, lib, config, ... }: {
imports =
[
./hoardingcow-mount.nix
];
}

View File

@@ -0,0 +1,125 @@
# AI Worker Restricted Access
This module provides SSH access for the AI worker (hermes-agent) to run docker commands on the host with restrictions.
## Security Model
### Overview
The `ai-worker` user is a member of the `docker` group, but the `docker` binary is wrapped with a script that **blocks dangerous subcommands** while allowing safe operations.
### Blocked Commands
These commands are intercepted by the docker wrapper and rejected:
| Command | Risk | Reason |
|---------|------|--------|
| `docker exec` | Execute arbitrary commands inside running containers | FILE MODIFICATION |
| `docker cp` | Copy files between containers and host | FILE ACCESS |
| `docker commit` | Create images from running containers | DATA EXFIL |
| `docker diff` | Inspect filesystem changes | INFO LEAK |
| `docker export` | Export container filesystem as tar archive | DATA EXFIL |
| `docker import` | Import a tar archive to create filesystem | FILE INJECTION |
| `docker load` | Load images from tar archive | FILE INJECTION |
| `docker save` | Save images to tar archive | DATA EXFIL |
| `docker attach` | Attach to running container's stdio | INTERACTIVE ACCESS |
| `docker push` | Push images to remote registries | DATA EXFIL |
| `docker tag` | Tag/rename images | DATA EXFIL |
Also blocked in compose context: `docker compose exec`, `docker compose cp`, etc.
### Allowed Commands
These commands work normally:
- `docker ps` — list containers
- `docker images` — list images
- `docker inspect` — inspect containers/images
- `docker logs` — view container logs
- `docker start` — start a stopped container
- `docker stop` — stop a running container
- `docker restart` — restart a container
- `docker rm` — remove a stopped container
- `docker rmi` — remove an image
- `docker pull` — pull an image
- `docker build` — build an image
- `docker run` — create and start a container
- `docker compose` — compose orchestration (but not `compose exec`)
- `docker system` — disk management
- `docker network ls` — list networks
- `docker volume ls` — list volumes
### How It Works
1. A wrapper script intercepts `docker` calls in the user's PATH
2. It parses the first non-flag argument to determine the subcommand
3. If the subcommand is in the blocklist, it prints an error and exits
4. Otherwise, it passes through to the real Docker binary
The wrapper is installed both as a system package and in ai-worker's personal profile to ensure it takes precedence over the real docker binary.
### Why Not Use Docker Authorization Plugins?
Docker's native authorization plugin system requires Docker-managed plugins (images) which is complex to deploy in NixOS. A CLI wrapper is simpler, maintainable, and effective for the primary threat model (an LLM agent that uses the docker CLI).
Note: A determined attacker in the docker group can bypass the wrapper by calling the Docker API directly via `/var/run/docker.sock`. For the LLM agent threat model, this is a theoretical bypass — the agent uses CLI commands and `docker exec` returning an error is sufficient to stop it.
### Filesystem Access
- **Home directory**: `/home/ai-worker` (standard user home)
- **No bind mounts**: Cannot access `/home/gortium/infra` or other host files
- **Cannot access**: Any files outside standard system paths
## SSH Access
Connect as:
```bash
ssh ai-worker@lazyworkhorse
```
The working directory will be `/home/ai-worker`. No infra repo access.
## Verification
```bash
# Verify wrapper is in PATH
sudo -u ai-worker which docker
# Should show: /home/ai-worker/.nix-profile/bin/docker (wrapped version)
# Test blocked command (should fail)
sudo -u ai-worker docker exec ollama ollama list
# Expected: ERROR: docker 'exec' is blocked by security policy
# Test allowed command (should work)
sudo -u ai-worker docker ps
# Expected: CONTAINER ID IMAGE ...
# Verify docker group membership
groups ai-worker
# Should show: ai-worker docker
```
## Troubleshooting
If docker commands fail unexpectedly:
```bash
# Check which docker binary is being used
which docker
# If this shows /run/current-system/sw/bin/docker, the wrapper is not in PATH
# Check if the wrapper is installed
ls -la $(which docker)
# Verify you're running as the right user
whoami
```
If SSH connection fails:
```bash
# Check SSH key is authorized
cat /home/ai-worker/.ssh/authorized_keys
# Check SSH service
systemctl status sshd
```

View File

@@ -0,0 +1,124 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Docker subcommands that are BLOCKED for ai-worker
# These commands allow file modification inside containers or data exfiltration.
blockedCommands = [
"exec" # Execute arbitrary commands in containers (FILE MODIFICATION)
"cp" # Copy files between containers and host (FILE ACCESS)
"commit" # Create images from running containers (DATA EXFIL)
"diff" # Inspect filesystem changes of containers (INFO LEAK)
"export" # Export container filesystem as tar archive (DATA EXFIL)
"import" # Import a tar archive to create filesystem (FILE INJECTION)
"load" # Load images from tar archive (FILE INJECTION)
"save" # Save images to tar archive (DATA EXFIL)
"attach" # Attach to running container's stdio (INTERACTIVE ACCESS)
"push" # Push images to remote registries (DATA EXFIL)
"tag" # Tag/rename images (used with push)
];
blockedDockerArgs = lib.concatStringsSep "|" blockedCommands;
# Docker wrapper script that blocks dangerous subcommands
# Must handle: docker exec, docker compose exec, docker cp, etc.
restrictedDockerScript = pkgs.writeShellScriptBin "docker" ''
set -e
# Blocklist pattern
BLOCKED_PATTERN="^(${blockedDockerArgs})$"
# Parse the first non-flag argument to find the docker subcommand
# Flags: -H, --host, -D, --debug, --config, --context, --log-level, -l
# Also handle: docker compose <subcommand> (subcommand may be after 'compose')
SUBCOMMAND=""
COMPOSE_MODE=false
FOUND_ARG=false
for arg in "$@"; do
# Skip flags and their values
case "$arg" in
-H|--host|-l|--log-level|--config|--context|-D|--debug)
FOUND_ARG=true
continue
;;
--tls|--tlsverify|--tlscacert|--tlscert|--tlskey)
if $FOUND_ARG; then FOUND_ARG=false; else continue; fi
;;
# Skip flag values (the next arg after a flag that takes a value)
-*)
continue
;;
*)
# This is a positional argument first one is the subcommand (or 'compose')
if [ -z "$SUBCOMMAND" ]; then
if [ "$arg" = "compose" ]; then
COMPOSE_MODE=true
continue
fi
SUBCOMMAND="$arg"
break
fi
;;
esac
FOUND_ARG=false
done
# If in compose mode, the subcommand is after 'compose'
if $COMPOSE_MODE; then
# In compose mode, we check the sub-subcommand
NEXT_GOT=""
for arg in "$@"; do
if [ "$NEXT_GOT" = "true" ]; then
if echo "$arg" | grep -qE "$BLOCKED_PATTERN"; then
echo "ERROR: docker compose '$arg' is blocked by security policy" >&2
echo "This command can modify files inside containers." >&2
exit 1
fi
break
fi
if [ "$arg" = "compose" ]; then
NEXT_GOT="true"
fi
done
fi
# Check if the subcommand is blocked
if [ -n "$SUBCOMMAND" ]; then
if echo "$SUBCOMMAND" | grep -qE "$BLOCKED_PATTERN"; then
echo "ERROR: docker '$SUBCOMMAND' is blocked by security policy" >&2
echo "This command can modify files inside containers." >&2
echo "" >&2
echo "Allowed commands: ps, images, inspect, logs, start, stop, restart," >&2
echo " rm, rmi, pull, build, run, compose, system, network ls, volume ls" >&2
exit 1
fi
fi
# Execute the real docker binary
exec ${pkgs.docker}/bin/docker "$@"
'';
in
{
options.services.aiWorkerAccess = mkOption {
type = types.bool;
default = false;
description = "Enable AI worker SSH access with restricted docker commands";
};
config = mkIf config.services.aiWorkerAccess {
# ai-worker is in docker group for normal docker operations
users.groups.docker.members = [ "ai-worker" ];
# Install the docker wrapper for ai-worker
# This puts a filtered 'docker' script in ai-worker's PATH that blocks
# dangerous commands like exec, cp, commit, etc.
# The real docker binary is still available at its store path, but the
# wrapper intercepts it because ~/.nix-profile/bin/ comes before /run/.../sw/bin/ in PATH.
users.users.ai-worker.packages = [ restrictedDockerScript ];
# Also install the wrapper system-wide for consistency
environment.systemPackages = [ restrictedDockerScript ];
};
}

View File

@@ -1,6 +0,0 @@
{ pkgs, lib, config, ... }: {
imports =
[
./systemd
];
}

View File

@@ -9,9 +9,15 @@ with lib;
path = mkOption { type = types.str; };
envFile = mkOption { type = types.nullOr types.path; default = null; };
ports = mkOption { type = types.listOf types.int; default = [ ]; };
# New option to pass raw systemd serviceConfig
serviceConfig = mkOption {
type = types.attrs;
default = { };
description = "Extra systemd serviceConfig options for this stack.";
};
};
});
default = {};
default = { };
};
config = {
@@ -23,28 +29,29 @@ with lib;
systemd.services = mapAttrs' (name: value: nameValuePair "${name}_stack" {
description = "Docker Compose stack: ${name}";
# Added 'docker.socket' to both after and wants to ensure the API is reachable
# Forces systemd to restart when the files change
reloadTriggers = [
"${builtins.hashFile "sha256" (toString value.path + "/compose.yml")}"
] ++ (lib.optional (value.envFile != null) "${value.envFile}");
after = [ "network.target" "docker.service" "docker.socket" "agenix.service" ];
wants = [ "docker.socket" "agenix.service" ];
requires = [ "docker.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
path = with pkgs; [ git docker docker-compose bash ];
# We merge the base config with the custom 'serviceConfig' from the submodule
serviceConfig = recursiveUpdate {
Type = "oneshot";
WorkingDirectory = value.path;
User = "root";
# This line forces the service to wait until the docker socket is actually responsive
ExecStartPre = "${pkgs.bash}/bin/bash -c 'while [ ! -S /var/run/docker.sock ]; do sleep 1; done'";
ExecStart = "${pkgs.docker-compose}/bin/docker-compose up -d --remove-orphans";
ExecStop = "${pkgs.docker-compose}/bin/docker-compose down";
RemainAfterExit = true;
# Ensure the environment file is passed correctly
EnvironmentFile = mkIf (value.envFile != null) [ value.envFile ];
};
} value.serviceConfig;
}) config.services.dockerStacks;
};
}

View File

@@ -1,45 +1,87 @@
{ pkgs, ... }: {
systemd.services.init-ollama-model = {
description = "Initialize LLM models with extra context in Ollama Docker";
after = [ "docker-ollama.service" ];
# On s'assure que Docker tourne avant de lancer ce script
after = [ "docker.service" ];
wantedBy = [ "multi-user.target" ];
script = ''
# Wait for Ollama
while ! ${pkgs.curl}/bin/curl -s http://localhost:11434/api/tags > /dev/null; do
sleep 2
done
# Fonction de création asynchrone pour ne pas bloquer le démarrage
(
echo "Starting asynchronous Ollama initialization..."
# Attente d'Ollama (maximum 120 secondes pour éviter une boucle infinie)
TIMEOUT=60
COUNT=0
while ! ${pkgs.curl}/bin/curl -s -f http://127.0.0.1:11434/api/tags > /dev/null; do
if [ $COUNT -ge $TIMEOUT ]; then
echo "Ollama did not become ready in time. Exiting."
exit 1
fi
echo "Waiting for Ollama API to be reachable..."
sleep 5
COUNT=$((COUNT + 5))
done
create_model_if_missing() {
local model_name=$1
local base_model=$2
if ! ${pkgs.docker}/bin/docker exec ollama ollama list | grep -q "$model_name"; then
echo "$model_name not found, creating from $base_model..."
${pkgs.docker}/bin/docker exec ollama sh -c "cat <<EOF > /root/.ollama/$model_name.modelfile
create_model_if_missing() {
local model_name=$1
local base_model=$2
# Vérification robuste via l'API HTTP d'Ollama plutôt que docker exec (évite les conflits de tty)
if ! ${pkgs.curl}/bin/curl -s http://127.0.0.1:11434/api/tags | ${pkgs.jq}/bin/jq -e ".models[] | select(.name == \"$model_name\")" > /dev/null; then
echo "$model_name not found, creating from $base_model..."
# Utilisation d'un fichier temporaire sur l'hôte pour l'injecter proprement dans Docker
TMP_FILE=$(mktemp)
cat <<EOF > "$TMP_FILE"
FROM $base_model
TEMPLATE """{{- if .System }}
[SYSTEM_PROMPT]
{{ .System }}
[/SYSTEM_PROMPT]
{{- end }}
{{- range .Messages }}
{{- if eq .Role "user" }}
[INST]
{{ .Content }}
[/INST]
{{- else if eq .Role "assistant" }}
{{ .Content }}
{{- end }}
{{- end }}"""
PARAMETER num_ctx 131072
PARAMETER num_predict 4096
PARAMETER num_keep 1024
PARAMETER repeat_penalty 1.1
PARAMETER top_k 40
PARAMETER stop \"[INST]\"
PARAMETER stop \"[/INST]\"
PARAMETER stop \"</s>\"
EOF"
${pkgs.docker}/bin/docker exec ollama ollama create "$model_name" -f "/root/.ollama/$model_name.modelfile"
else
echo "$model_name already exists, skipping."
fi
}
PARAMETER stop "[INST]"
PARAMETER stop "[/INST]"
PARAMETER stop "</s>"
EOF
# Create Nemotron
create_model_if_missing "nemotron-3-nano:30b-128k" "nemotron-3-nano:30b"
# Create Devstral
create_model_if_missing "devstral-small-2:24b-128k" "devstral-small-2:24b"
# Copie et création dans le conteneur
${pkgs.docker}/bin/docker cp "$TMP_FILE" ollama:/tmp/model.modelfile
${pkgs.docker}/bin/docker exec ollama ollama create "$model_name" -f /tmp/model.modelfile
${pkgs.docker}/bin/docker exec ollama rm /tmp/model.modelfile
rm -f "$TMP_FILE"
else
echo "$model_name already exists, skipping."
fi
}
# Create Nemotron
create_model_if_missing "nemotron-3-nano:30b-128k" "nemotron-3-nano:30b"
# Create Devstral
create_model_if_missing "devstral-small-2:24b-128k" "devstral-small-2:24b"
) &
'';
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Type = "forking"; # Permet à systemd de savoir que le script passe en arrière-plan via '&'
User = "root";
};
};
}

View File

@@ -20,11 +20,7 @@ in {
environment.etc."opencode/opencode.json".text = builtins.toJSON {
"$schema" = "https://opencode.ai/config.json";
"model" = "devstral-2-small-llama_cpp";
# MCP servers for web search and enhanced functionality
# context7: Remote HTTP server for up-to-date documentation and code examples
# duckduckgo: Local MCP server for web search capabilities
"model" = "nemotron-3-nano-llama_cpp";
"mcp" = {
"context7" = {
"type" = "remote";
@@ -46,6 +42,7 @@ in {
"options" = {
"baseURL" = "http://localhost:8300/v1";
"apiKey" = "not-needed";
"maxTokens" = 80000;
};
"models" = {
"devstral-2-small-llama_cpp" = {
@@ -53,6 +50,11 @@ in {
"tools" = true;
"reasoning" = false;
};
"nemotron-3-nano-llama_cpp" = {
"name" = "Nemotron 3 nano 30B Q8 (llama.cpp)";
"tools" = true;
"reasoning" = false;
};
};
};
"ollama" = {
@@ -76,6 +78,7 @@ in {
systemd.services.opencode-gsd-install = {
description = "Install Get Shit Done OpenCode Components";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = with pkgs; [
nodejs
@@ -131,7 +134,6 @@ in {
environment = {
OLLAMA_BASE_URL = "http://127.0.0.1:11434";
# Important: GSD at ~/.config/opencode, so we ensure the server sees our /etc config
OPENCODE_CONFIG = "/etc/opencode/opencode.json";
HOME = "/home/gortium";
NODE_PATH = "${pkgs.nodejs}/lib/node_modules";

View File

@@ -0,0 +1,64 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.openclaw-node;
openclawPkg = pkgs.openclaw;
in {
options.services.openclaw-node = {
enable = lib.mkEnableOption "OpenClaw Node service";
user = lib.mkOption {
type = lib.types.str;
default = "ai-worker";
description = "User to run the OpenClaw headless node as.";
};
gatewayHost = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Gateway host (IP or hostname).";
};
gatewayPort = lib.mkOption {
type = lib.types.int;
default = 18789;
description = "Gateway WebSocket port.";
};
gatewayTokenFile = lib.mkOption {
type = lib.types.str;
default = "";
description = "Path to file containing the gateway auth token.";
};
displayName = lib.mkOption {
type = lib.types.str;
default = "lazyworkhorse-host";
description = "Display name for this node (shown in pairing).";
};
};
config = lib.mkIf cfg.enable {
systemd.services.openclaw-node = {
description = "OpenClaw Headless Node Service";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "exec";
User = cfg.user;
Group = cfg.user;
WorkingDirectory = "/home/${cfg.user}";
ExecStart = ''
${pkgs.bash}/bin/bash -c 'export OPENCLAW_GATEWAY_TOKEN=$(cat ${cfg.gatewayTokenFile}) && exec ${openclawPkg}/bin/openclaw node run --host ${cfg.gatewayHost} --port ${toString cfg.gatewayPort} --display-name "${cfg.displayName}"'
'';
Restart = "always";
RestartSec = 5;
};
environment = {
NODE_ENV = "production";
};
};
};
}

View File

@@ -1,24 +1,36 @@
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IEdoTUQ4QSBGWmpW
bFFuT1FNWVlsd0twcUJnYXV0T0Z3Q0RDZldsNTUwWlprQTJaK2xNCmMzS3g1OEdI
bENzekRFTkIwbVRua2MzTVdnZmNKMnd6dzJjZEx5eXhBWmMKLT4gV2ktZ3JlYXNl
IChaQl14QSB3IFlIcmkKVHZPSmZ2aXNaSHVUbi9UbUNTL00ycWRZbzVwTlFUUjls
Z2RFSGMyM2ZDbkRlekxxemR4RTlLWnI3L0FlanpkYgpaaUlpSFdxZlo0Sk9XcXF3
TnZQYzY1MWxLRklycWh3MEl2ZENSMk5yMDNKNWkyZmVBNTlSNWxBSzZ2RDNmeDRP
CgotLS0gNEtpRlhJbkZXcGNpQzBFREhCempyYlFHcTRHSlpTOUZFeGxmNHk2c20x
VQrxqxWUB/GZUQixOXxdZhfeUDyzbc7DZ4CMA8o0X0NHxxonsHQXvAwcHFYVBj45
d7D9yjtHYP+EAR2skUEnlPYfUdFKtjyE4KRE/wv6VQXfjeIax0USypvuEg9e+cfA
VknSLO4G+si8MvccJNZsBGGebEg8OpmSqSog6pee3jeVtr0fr5no0901rnwZYQEN
X63i+8cp2ZnHCxuR6ol48rUB9AEieYiYvI8gCfATigvFkjj/fEYKLK/kgqLVl96p
CjtXqhO0XGROPCvyVB8yadJCw67tMdkZO39saJTeHP6r0lz37lHNm8Uwyel89kLd
CWqrIK67MH1ejXwhTfQlHSX3WQYAXfxq7fmetjcJb0NBXUBsPrAwlmz49T0TWvfa
1oi60xLD+BsKR3KDgthid3GwhcrsY5RA8y8x8c4Ssk1iLKEIlyOM+f2cYJRvYMrS
LfSs1cvIORLA8QcADELhzV7mVsBtXo8vU5oSoCWrvT0vs2H2EFvl4Qfx/8UGoVMK
p3HFMw3Qwxh2Qyr6kD6SuRc1dzbseXiBtPuN76KOQNbo9LEu0JNwsoHqv7wdUS6u
r831UKyTxWfl3oBUzldG2Ugka3/7wr3n2biARkADNjrvkFHo5BM6vYla583j6ml3
/IzQOIQXSmgv+opza1oghf2jg9UFkMOPZ9iz6srg2xaH+xZ7+xnL3cuY4ngWwIqy
pRKdcrNDOIawhEpJEAUYLHMcrCCekZPJalEcMZ26pXjVG1p9SYVsQWxkpVgOqEIH
8Q4zYMYQAQssVSED3SrQ39giW7+UfGnoqsy9qTq1UvDBpnGDMk2JYsGZmQoWEvtJ
AudwoHTFj/szABXE7qootqjGGhopdC0pFWGKaSFRre7iIeiYNJDXYi1lyAtDfZFW
iv8avbywunozAigA8+wuF4Zw1GOThPAOLNU=
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IEdoTUQ4QSBWNEpt
cGFNeVBBaDRqb3pLSEZGQW0wb3VmVnBoZCswUFkzbnBLUnJ0QTNrClRqVkk4RUVO
d29KYjd5YUcwankvaTFmVHUxQVpDT2ZYWHRaY3JXTUtQMU0KLT4gKXBtQ3UsXi1n
cmVhc2UgNnwxYCBXVyA/KCQmIHt9NAo3OTZVUHR2UXkvaEFwY0ZBdEJsaFpsbHJ0
cklKcDVHcEdWMEdPSkpnN0FiRU43RW5hUWFMdjR3WFRRSFBLSGlmClM3cTNJWlNM
TExkdHdXUHJISkNIaE1TTUxUc1NUWkV1a09HeFU3bVZwQXMKLS0tIGhOcXFTUElS
azJJNnEreUhMWTJBaVZGSTJPRUFqQkVYS01KRENUVVpZSDgK8+8onFejroBo7MeO
dW+so4lOsq4zJKn3f0cxmCFg1f0X8zt6h4Uc3A5Cvr1uU+6yw1FWmJ7xa3jJz3lO
EEaKQJXYC+xIIKGcA7qILa0SFp4a/4OuYjcg27HrlPhg7u5wDhQrd0LdVEe1Xngp
ZivX7P7HwIna3X8C+TL+K2v/AG2N/z86cdKfRvxyMKNbHhYw+CfHEnWgh8tJ++4h
G9evNniuNqte6cQaRe7jODfPNW4FuY/Sb7barlJ/M9iAQdYAdyLAzU1LABeHeUfD
wtHjxy9DUZ55Vg8bB8M2JJU9MkoRT4ewiVd9LeC1GWeVmKsm93wsmrov714i7U2j
wHtDkjqEF2MmzuQc18sjNaAHiwz8j6o5xU2L/Q4+Q707yISWG7RGZYh389Cr1rnw
siUq/Vunqw2wk13+J/4vu9nqt5mMktBaCtp+QiWIurjwB5LUAyChrSm+dg5lb0Mt
UhSc0lq1+E3vxAXM2Hmk+vP86VD+6WJvAU82VFApF1s6zG2FU1/AcOVVf54nan/q
f+rgSFfASHQCYSblUJHyEtwLNsWEmTGmOEn1buUKD/H0zatPQnc0rYpjlx2V0Sjd
6yB5+wPrZ0AkN1pjcsPKOv8Kaog2DzqIjib+SaSTaRxWHQEb9uzvaReAcYI5HOpE
gkC040HN33BItATbo4+hz70Im8Ni/VXD+g6yzM6Hj1hJL+PinTKeg5keQRFIZjMx
grzievB2wVBBgLgN3qMdTFmpplaL7iL702JjXZUTTK9Izp+9wiCsV1fTa53FWDht
ylFL5SWElqXjK+QBXxAe+Jk6VQov5HI21YDXL67S554ABeRok23wxrQ31TCI4xq9
PQV7VtNRjyVud7S29m3OwpWOsgTZhn+JclHj2v4bNJzJkJnZRTmcvGPktzRI5+R4
e5vxVhGnJDzI71txaHl8+xS1lu9VzCQUrxX6TXyTRV4KjIOz0g06JOBgmBRBvJca
7MZbC65xpisl/gyLRbgkVga3t94dPV+dpZsn8eq6427IyRbKslJefatggR9//c6I
5N5fl0fR3gJQMB+HRbipBH2YsdbdWJyb4Nn6STZxIfrqoG/xC6C1raF0xK7hUx6i
4DUDSPohM8fOIswQPfE+FH3eygfzu/Ln5+ghsgHTEhgFvmgMvyxaAt6kHIzIUhMX
M3dASr4VPDpIXuXsRWwYLEifhzxsuvwVxfwtsnCaR6XKijsYECWGDdYOWHdleeqx
wDPhxEesfFVhKxhrKY9Ir8k9/FFBKQU/3GjW4+SMAg5Al1YEzxshP9vKuVcsei7W
JDwAwotNXaCm6NBckiyZJE53ou6+gckPY7V9cOfnuH74Z9ywkFzB3HW3ZlonaGyM
oGmLGcccavFtyhg5s/As4i6X8ARIpDiwe59Pn3GNXMctySqIrrr2ogUoXgrfFCie
6GOTdeMW7GeOSdJUxCofghlspS/nq01Og77VI/beWYrIwLubSka6Zaltww9zgObk
/FGEMgFkEpq7iyCvYSPA8F46pJKvnMP3S84AWCPmcTcHeg4lwGPvs6btexXBGdoz
nkCyq7wdH5Nngm7jUbl88LtaLZPAQkuqXphBVTnrF9Ofbnb4iRZ2Op4xpx9rGyvx
mO6UEhL6V1i2YZFNkNMg/W8aoMiUgBdqbkxaxblT9L0aNdlFU9+LbWYolURVEadd
Qjv0Z1gMA+tsuBbVszwsMfneZ5+B9Q==
-----END AGE ENCRYPTED FILE-----

View File

@@ -0,0 +1,11 @@
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IEdoTUQ4QSBCWEpO
cG9yNnFpcHFqTkNzTngxU1MxN0NYK0hrZFhUTjVORWFrK3JNd2tZCmtMTGpwQk1E
WlUwL3N6SGRWblpnNEkrWkkyU2hQMkRIK0M3R0pOVEREV3MKLT4gY2osLWdyZWFz
ZSBacSozVVQgUCAxRS1OQSAuKXxDPCoKbStWNW1BZjBZQzNDaTlDbU5EZkxsRWxM
cXJ3dDU1RDNpOXRlV0tzdEp2NUo3S1lhRG5Md0RHTGlJdkFSYmt5YQo4R1hiQWRG
V2VxekJKZwotLS0geG1XSi9VbkhXZHQzcEFVS3hKNzVueXFLa2xnZTc3Q2tJTVZ5
eXJabWk5Ywp6bJCP3s0xxzjE+eTR+cv7ZUnkoliT/n7uIprq1BTn/LIRLkUTUqs3
NiDwrXcoq4/QKd0Dt+8ap3vFAuusjGxRlnYMaRrZie2AGtTV8U7Q7durm9o2K+/4
QzRQ/MtumIQm
-----END AGE ENCRYPTED FILE-----

View File

@@ -10,4 +10,5 @@ in
"containers.env.age".publicKeys = authorizedKeys;
"lazyworkhorse_host_ssh_key.age".publicKeys = authorizedKeys;
"n8n_ssh_key.age".publicKeys = authorizedKeys;
"openclaw_gateway_token.age".publicKeys = authorizedKeys;
}

View File

@@ -0,0 +1,9 @@
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IEdoTUQ4QSA3VG9Z
MVFPVFc2VVJ3d0h0dmtBUnI3WHl2SzUxTkRZbjFCaGloWmV3dnd3ClcxdnVPeGd6
SU4zR0Q0K1dtVjRRVHd0VW5XSFI0dVFpTjZnYk1DNjRxTVEKLT4gQzlgRy1ncmVh
c2UKeUozOWgyUytSTVF0NjY2STBEb2VadwotLS0gblI3bmJCUWxxU3QrYTEyVFBI
Snc4NC9rTkh0NnZYbUtxUE9hRWRkelpmMAq58fmH6cK13GeD7wGLxKmx10hmJeW4
b7KqnCD1ZP7uG85s32xzVRwRG8RrG4xZo5nR9Mrtg1CoTSFfUGeFnf5xveN+Ej0X
wDVB1LwC+Q==
-----END AGE ENCRYPTED FILE-----

View File

@@ -0,0 +1,11 @@
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IEdoTUQ4QSA5dzVG
WUNvT3NlRmcrWS81bzJqSWlTekVYaDFFTE10SkI2dEgzaGpxcUI4Cmk5Y0FGYTRZ
K0NGYzY3VUp4aS9ZZGRmWTgybDJFUURva2pZNmVOS3QxdEUKLT4gPnVRTCtldGMt
Z3JlYXNlCk04OTJZeFRNeDI5aGpMVTk1ZTE0Y2FMMnFEMjlJalJpMHRlaTE4ZWIx
d2lCRGQ5RHVjcktOMGJCb1VERlNWcTYKaSt0L1Z6dVJ0QWIyZkhsYzFEVjZSQWUr
ZWpwVlo1TmhoUFJZdkEvR0gxNlVhcXF2ZTRnCi0tLSBLcmM2MThNVkdWclpHUXRr
VTF6QVk2WUZlTXpZMVNLMlpBOFc3M1o5WjZzCs9xbPlIX+u5vRSQ/z9utu+I9S2c
02DOsIb1kzxzb1OK91b8Kh4JucQSq3qkyEvRucsNn5QW8hIHDnRuND6EbPyN7p4S
YB/F0dxSqgnq
-----END AGE ENCRYPTED FILE-----

83
users/ai-worker.nix Normal file
View File

@@ -0,0 +1,83 @@
{ pkgs, inputs, config, keys, ... }: {
users.users.ai-worker = {
isSystemUser = true;
group = "ai-worker";
home = "/home/ai-worker";
createHome = true;
# ai-worker stays in docker group for normal docker operations (ps, start, stop, compose, ...)
# Dangerous commands (exec, cp, commit) are blocked by a wrapper script.
extraGroups = [ "docker" ];
shell = pkgs.bashInteractive;
openssh.authorizedKeys.keys = [
keys.users.ai-worker.main
];
# No password login - SSH key only
hashedPassword = "!";
};
users.groups.ai-worker = {};
# Enable restricted AI worker SSH access
# SECURITY: ai-worker is in docker group but docker commands are filtered:
# ALLOWED: ps, images, logs, start, stop, restart, rm, rmi, pull, build, run, compose
# BLOCKED: exec, cp, commit, diff, export, import, load, save, attach, push
# The filtering is done by a docker wrapper in ai-worker's PATH.
services.aiWorkerAccess = true;
# Restricted sudo for ai-worker - security checks only (not for docker)
security.sudo.extraRules = [
{
users = [ "ai-worker" ];
commands = [
# Firewall checks
{
command = "/run/wrappers/bin/sudo iptables -L -n -v";
options = [ "NOPASSWD" ];
}
{
command = "/run/wrappers/bin/sudo iptables -S";
options = [ "NOPASSWD" ];
}
# Fail2ban status
{
command = "/run/current-system/sw/bin/fail2ban-client status";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/fail2ban-client status *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/fail2ban-client get * banned";
options = [ "NOPASSWD" ];
}
# Log inspection
{
command = "/run/current-system/sw/bin/journalctl -t kernel -n 100";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/journalctl -u fail2ban -n 50";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/journalctl -u firewall -n 50";
options = [ "NOPASSWD" ];
}
# SSH config verification
{
command = "/run/current-system/sw/bin/sshd -T";
options = [ "NOPASSWD" ];
}
# Network diagnostics
{
command = "/run/current-system/sw/bin/ss -tlnp";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/cat /proc/net/tcp";
options = [ "NOPASSWD" ];
}
];
}
];
}

View File

@@ -1,12 +0,0 @@
{ pkgs, inputs, config, keys, ... }: {
users.users.n8n-worker = {
isSystemUser = true;
group = "n8n-worker";
extraGroups = [ "docker" ];
shell = pkgs.bashInteractive;
openssh.authorizedKeys.keys = [
keys.users.n8n-worker.main
];
};
users.groups.n8n-worker = {};
}