No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ellis Rahhal ecd13629df flake: export nixosModules.homefree
HomeFree now composes `nixosModules.homefree` and falls back to `default`
for plugins that predate the convention. The two were interchangeable
while every plugin existed only to be a plugin — `default` had nothing
else it could be. A flake that is also something in its own right breaks
that: genai-server's `default` is a standalone NixOS service stack, and
registering it composed that stack into the router instead of its ingress
module, silently, with every subdomain answering "no service at this
host".

`default` stays as an alias so this keeps working on a HomeFree that
predates the change, and so the lock bump is not a flag day.
2026-08-12 15:48:22 -07:00
apps rssmonster updates 2026-08-12 10:02:06 -07:00
flake.lock librechat: pass the fleet's context sizes 2026-08-10 21:01:01 -07:00
flake.nix flake: export nixosModules.homefree 2026-08-12 15:48:22 -07:00
README.md readme: librechat is a sub-app, not an app of its own 2026-08-10 18:58:06 -07:00

homefree-genai

HomeFree apps for genai-server — TLS-fronted, SSO-gated ingress for a GenAI stack that may be running on another machine, plus the surfaces that are better off running here.

Why a plugin and not part of either project

genai-server is a generic service flake; HomeFree is a generic platform. Neither should know about the other. This repo is the adapter: it speaks HomeFree's app conventions (homefree.service-config, allocPort, caddy_gated SSO, homefree.containers, homefree.sso.clients) and points them at genai-server's endpoints.

The dependency runs one way: this repo takes genai-server as an input, for the definitions it renders. genai-server must never take this one — an input there is something every consumer of the stack has to be able to fetch, and a deployment's hostnames, its Zitadel and its port allocator are nobody else's.

The app

A parent app, genai, plus one sub-app per web surface. The parent holds only what is shared; each surface owns the decisions that are genuinely per-surface, because exposing ComfyUI to the WAN is a different act from exposing the chat UI.

homefree.services = {
  genai = {
    enable = true;                  # master switch — gates every surface
    backend-host = "logistikon.lan";
  };

  # Sub-apps default to enabled, SSO on, WAN off. Override as needed:
  genai-comfy.enable = false;       # don't publish ComfyUI at all
  genai-voice.sso = false;          # headless client that can't do OIDC
  genai-webui.public = true;        # reachable from the WAN
};
sub-app subdomain fronts
genai-api ai the portal — /v1/* and /svc/*, the whole stack
genai-webui webui Open WebUI
genai-comfy comfy ComfyUI
genai-search search SearXNG
genai-magentic magentic Magentic-UI
genai-voice voice realtime speech-to-speech
genai-librechat librechat LibreChat — runs here, see below

Defaults: sub-apps enable = true, sso = true, public = false. Turning on the parent publishes the stack behind SSO on the LAN; turning the parent off takes everything down regardless of the child toggles.

The surfaces are instances, not separate apps. They nest under the GenAI card the same way MediaWiki sites nest under MediaWiki: the parent declares an instances list, each entry becomes a sub-app labelled genai_<subdomain>.

That label is not a choice — the admin UI derives it as ${parentLabel}_${inst.subdomain} and matches instances by that string.

The array has to exist in /etc/nixos/homefree-config.json. The admin UI reads and writes services.genai.instances there and merges no Nix schema defaults into it, so on a box whose config predates this the sub-app toggles have nothing to write to — they revert a second after being set, and the Config dialog comes up empty. Seed it once (see below); after that the UI owns it, exactly as it owns the MediaWiki site list.

Setting parent WITHOUT a matching instances entry is what caused both of those symptoms. parent does not mean "group these" in HomeFree; it means "entry in the parent's instances array", and three handlers in services-module.js hard-code that.

SSO is the whole security story here. Nothing behind these subdomains authenticates — the portal, ComfyUI and the tool servers were built for a trusted LAN — so the Caddy gate is the only thing between the internet and an inference API with a code sandbox on it. sso.kind is metadata the admin UI reads and gates nothing; the gate is reverse-proxy.oauth2, derived from each surface's sso toggle. That distinction cost an open endpoint once.

The genai app runs nothing

No container, no port, no data. Every surface it publishes fronts a service on the machine with the GPU — the portal in particular reads the model store, render directory and eval reports off local disk, so it has to live there. Getting the portal itself onto HomeFree means putting those disk-bound surfaces behind a wire protocol first; that is tracked in genai-server's ROADMAP.

One thing to set on the genai-server side

The portal builds its directory links from the host you are reading it on, which behind a proxy yields https://ai.<domain>:3000 for Open WebUI — a port not published there. Tell it where things really are:

services.genai-server.portal.serviceUrls = {
  "Open WebUI"     = "https://webui.<domain>";
  "ComfyUI"        = "https://comfy.<domain>";
  "SearXNG"        = "https://search.<domain>";
  "Magentic-UI"    = "https://magentic.<domain>";
  "Realtime voice" = "https://voice.<domain>";
};

The voice page needs the last one specifically: it asks the portal where the voice service is published, and without it dials the page's own host.

LibreChat runs HERE, and why it is the exception

Every other surface is ingress: Caddy points at a service on the machine with the GPU, and the gate in front is that service's identity. That works because those services take an identity from a header.

LibreChat does not. Its strategies are openid, saml, ldap and local — no header, no proxy mode — so behind the gate it asked for a SECOND login, which is the one thing a single sign-on exists to prevent. The only fix is for LibreChat to speak OIDC to Zitadel itself, and an OIDC client's id and secret are minted by Zitadel into /var/lib/homefree-secrets/librechat on this box. Nothing can hand them to the GPU box at evaluation time, and shipping them afterwards means a hand-copied credential or a new trust edge between two machines. Running it beside the provider costs neither: it wants no GPU and reaches the fleet over HTTP like any other client.

It is still a sub-app of GenAI, with no settings of its own — the app-wide backend-host is where it looks for the stack, exactly like the surfaces that are only ingress. Two fields in surfaces.nix carry the difference:

  • local = true — ingress points at this box, and the port comes from HomeFree's allocator rather than being the remote service's fixed one, since it has to coexist with every other app here.
  • nativeSso = truesso.kind becomes native_oidc and reverse-proxy.oauth2 goes off. Three cases, not two: gated by oauth2-proxy, gating itself against the same provider, or neither. This is not an ungated surface — it is one whose gate is its own.

Until Zitadel has provisioned the client the generated oidc.env is empty and LibreChat keeps its own login rather than offering a sign-in that cannot complete. It comes up on the restart post_restart_units triggers, with no second rebuild.

It talks to the stack through one URL, because genai-server's portal is a single API front door: /v1/* is the fleet (availability gate and GPU arbiter applied) and /svc/mcp/mcp is the MCP gateway. The portal's port is read out of the surface table, not written down twice.

The definition is not written here. genai-server exports it as lib.librechat — the container pair, the generated librechat.yaml and the two env-file scripts, in the field names homefree.containers.<name> uses. A second copy of the image tag, the Mongo wiring or that YAML in this repo is the drift both repos are organised against, and is why this flake finally takes the input its own comment predicted it would need. Bump it with nix flake update genai-server — an input is the one thing rsync cannot carry, since nix will not lock a staged-but-uncommitted tree.

Status

Ingress, plus one container app.

Every surface but one is ingress; genai-librechat runs here. Container apps for the CPU-capable services (voice, tts, rag, memory, mcp, media-tools) are next; they use the same input, for lib.serverScripts.