Skip to content

State Management Architecture ​

Lamaste stores all state in JSON and YAML files on disk — no database, no external services. State changes use atomic writes to prevent corruption from crashes or power loss.

In Plain English ​

Lamaste does not use a database. All configuration and state is stored in plain files on the VPS:

  • panel.json holds the system configuration (IP address, domain, onboarding progress)
  • tunnels.json holds the list of configured tunnels
  • sites.json holds the list of configured static sites
  • invitations.json holds pending user invitations
  • users.yml holds the Authelia user database (usernames, password hashes, TOTP secrets)

These files are small (a few kilobytes each) and are read/written atomically — meaning the system writes to a temporary file first, then renames it into place. If the server crashes mid-write, the old file is preserved intact. You never end up with a half-written, corrupted file.

This approach works because Lamaste manages a small amount of state (one admin, a handful of tunnels and users). A database would add RAM overhead and operational complexity for no benefit at this scale.

Overview ​

/etc/lamalibre/lamaste/
├── panel.json              ← Central config (IP, domain, onboarding state)
├── tunnels.json            ← Tunnel definitions
├── sites.json              ← Static site definitions
├── chisel-credentials.json ← Per-agent Chisel credentials
├── chisel-users            ← Chisel authfile, rendered from credentials + tunnels.json (0640, group lamaste-chisel)
├── chisel-sentinel         ← Password of the no-grants sentinel Chisel user
├── invitations.json        ← Pending user invitations
├── plugins.json            ← Plugin registry (installed plugins, enabled state)
├── ticket-scopes.json      ← Ticket scope registry (scopes, instances, assignments)
├── tickets.json            ← Ticket and session store
├── storage-config.json     ← Storage server registry and plugin bindings
├── storage-master.key      ← 32-byte master key for storage credential encryption
├── groups.json             ← Lamaste groups (separate from Authelia groups)
├── access-grants.json      ← Generic access grants (principal → resource)
├── gatekeeper.json         ← Gatekeeper settings (cache TTL, admin contact, logging)
├── access-request-log.json ← Optional denied access log (for admin review)
└── pki/
    ├── ca.key              ← CA private key
    ├── ca.crt              ← CA certificate
    ├── client.key          ← Client private key
    ├── client.crt          ← Client certificate
    ├── client.p12          ← PKCS12 bundle for browser import
    ├── .p12-password       ← PKCS12 password
    ├── self-signed.pem     ← Self-signed TLS cert for IP vhost
    ├── self-signed-key.pem ← Self-signed TLS key for IP vhost
    ├── revoked.json        ← Revoked certificate serial numbers
    ├── enrollment-tokens.json ← One-time enrollment tokens
    └── agents/             ← Agent certificate storage
        ├── registry.json   ← Metadata for all agent certs
        └── <label>/        ← Per-agent directory
            ├── client.key  ← Agent private key
            ├── client.crt  ← Agent certificate
            └── client.p12  ← Agent PKCS12 bundle

/etc/authelia/
├── configuration.yml       ← Authelia main config
├── users.yml               ← User database (usernames, bcrypt hashes, groups)
├── .secrets.json           ← JWT + session + storage encryption secrets
├── db.sqlite3              ← Authelia session/TOTP storage
└── notifications.txt       ← Notification log (filesystem notifier)

/var/www/lamaste/
└── <site-uuid>/            ← Static site file roots (one per site)
    ├── index.html
    └── ...

Atomic Writes Pattern ​

All JSON state files use the same atomic write pattern, implemented in packages/lamaste-serverd/src/lib/state.js and packages/lamaste-serverd/src/lib/config.js:

1. Serialize data to JSON string
2. Write to <path>.tmp in the same directory
3. Open the temp file and call fd.sync() (fsync)
4. Rename temp file to final path

In code:

javascript
export async function writeTunnels(tunnels) {
  const filePath = tunnelsPath();
  const tmpPath = `${filePath}.tmp`;

  const content = JSON.stringify(tunnels, null, 2) + '\n';
  await writeFile(tmpPath, content, 'utf-8');

  // fsync: flush data to disk before rename
  const fd = await open(tmpPath, 'r');
  await fd.sync();
  await fd.close();

  await rename(tmpPath, filePath);
}

Why this works:

  • rename() is atomic on POSIX filesystems — it either completes fully or not at all
  • fd.sync() ensures the data is flushed to the physical disk before the rename
  • If the process crashes during writeFile, only the .tmp file is affected — the original state file is untouched
  • If the process crashes after fd.sync() but before rename(), the .tmp file has valid data and the original is untouched
  • The worst case is losing the most recent write, which preserves the previous consistent state

Why the temp file is in the same directory:

  • rename() is only atomic within the same filesystem
  • /etc/lamalibre/lamaste/tunnels.json.tmp and /etc/lamalibre/lamaste/tunnels.json are on the same filesystem, so the rename is guaranteed to be atomic

Config Schema (panel.json) ​

The central configuration file, validated by Zod on every load and update.

Location: /etc/lamalibre/lamaste/panel.json (production) or dev/panel.json (development)

Schema:

json
{
  "ip": "203.0.113.42",
  "domain": "example.com",
  "email": "admin@example.com",
  "dataDir": "/etc/lamalibre/lamaste",
  "staticDir": "/opt/lamalibre/lamaste/lamaste-server-ui/dist",
  "maxSiteSize": 524288000,
  "onboarding": {
    "status": "COMPLETED"
  }
}

Field definitions:

FieldTypeRequiredDescription
ipstringYesVPS public IP address (detected during install)
domainstring | nullYesBase domain (set during onboarding, null before)
emailstring (email) | nullYesAdmin email for Let's Encrypt (set during onboarding)
dataDirstringYesPath to state directory (/etc/lamalibre/lamaste)
serverIdstring (uuid)NoAuto-generated UUIDv4, bucket prefix for multi-server storage isolation
staticDirstringNoPath to lamaste-server-ui dist (overrides default resolution)
maxSiteSizenumberNoMaximum static site size in bytes (default: 500 MB)
adminAuthModeenumNo"p12" (default) or "hardware-bound"
panel2faobjectNoBuilt-in TOTP 2FA configuration
sessionSecretstring | nullNoHMAC key for signed session cookies (default: null)
onboarding.statusenumYesCurrent onboarding state

Zod validation:

javascript
const ConfigSchema = z.object({
  ip: z.string().min(1),
  domain: z.string().nullable(),
  email: z.string().email().nullable(),
  dataDir: z.string().min(1),
  serverId: z.string().uuid().optional(),
  staticDir: z.string().optional(),
  maxSiteSize: z
    .number()
    .optional()
    .default(500 * 1024 * 1024),
  adminAuthMode: z.enum(['p12', 'hardware-bound']).optional().default('p12'),
  panel2fa: z
    .object({
      enabled: z.boolean(),
      secret: z.string().nullable(),
      setupComplete: z.boolean(),
    })
    .optional()
    .default({ enabled: false, secret: null, setupComplete: false }),
  sessionSecret: z.string().nullable().optional().default(null),
  onboarding: z.object({
    status: z.enum(['FRESH', 'DOMAIN_SET', 'DNS_READY', 'PROVISIONING', 'COMPLETED']),
  }),
});

Validation runs:

  • At server startup (loadConfig())
  • Before every config update (updateConfig())
  • Invalid data throws a Zod error, caught by the error handler and returned as a 400

Config loading chain:

1. Check LAMALIBRE_LAMASTE_CONFIG environment variable → use if set
2. Check NODE_ENV:
   - "development" (or unset) → dev/panel.json relative to package root
   - "production" → /etc/lamalibre/lamaste/panel.json
3. Read and parse JSON
4. Validate with Zod schema
5. Cache in module-level variable

Config updates (updateConfig):

javascript
export async function updateConfig(patch) {
  // Deep clone current config
  const merged = structuredClone(config);

  // Merge patch (onboarding and panel2fa are merged as sub-objects, others replaced)
  for (const key of Object.keys(patch)) {
    if (key === 'onboarding' && typeof patch.onboarding === 'object' && patch.onboarding !== null) {
      merged.onboarding = { ...merged.onboarding, ...patch.onboarding };
    } else if (
      key === 'panel2fa' &&
      typeof patch.panel2fa === 'object' &&
      patch.panel2fa !== null
    ) {
      merged.panel2fa = { ...merged.panel2fa, ...patch.panel2fa };
    } else {
      merged[key] = patch[key];
    }
  }

  // Re-validate
  const validated = ConfigSchema.parse(merged);

  // Atomic write
  const tmpPath = `${configPath}.tmp`;
  await writeFile(tmpPath, JSON.stringify(validated, null, 2) + '\n', {
    encoding: 'utf-8',
    mode: 0o600,
  });

  // fsync: flush data to disk before rename
  const fd = await open(tmpPath, 'r');
  await fd.sync();
  await fd.close();

  await rename(tmpPath, configPath);

  // Update cache
  config = validated;
  return structuredClone(config);
}

The onboarding and panel2fa fields get special merge treatment — updating { onboarding: { status: 'COMPLETED' } } or { panel2fa: { enabled: true } } preserves other sub-fields rather than replacing the entire object.

All reads via getConfig() return a structuredClone, preventing callers from accidentally mutating the cached config.

Onboarding State Machine ​

The onboarding process follows a linear state progression stored in panel.json:

FRESH ──────────► DOMAIN_SET ──────────► DNS_READY
  │                    │                      │
  │ POST /domain       │ POST /verify-dns     │ POST /provision
  │ Sets domain+email  │ Confirms DNS ok      │ Starts provisioning
  │                    │                      │
  │                    │                      ▼
  │                    │               PROVISIONING
  │                    │                      │
  │                    │                      │ Background task completes
  │                    │                      │
  │                    │                      ▼
  │                    │                 COMPLETED
  │                    │                      │
  └────────────────────┘──────────────────────┘
                                              │
                                   Onboarding routes → 410 Gone
                                   Management routes → accessible

State transitions are enforced at multiple levels:

  1. Route guards — onboardingOnly() returns 410 for any onboarding route when status is COMPLETED; managementOnly() returns 503 when status is not COMPLETED
  2. Provisioning endpoint — validates that status is DNS_READY or PROVISIONING before starting
  3. Domain endpoint — validates that status is FRESH
  4. DNS endpoint — validates that status is DOMAIN_SET

Once provisioning completes and status is set to COMPLETED, onboarding routes are permanently locked out (410 Gone). The only way to re-run onboarding would be to manually edit panel.json, which is an intentional safety measure.

Tunnel State (tunnels.json) ​

Stored at /etc/lamalibre/lamaste/tunnels.json. An array of tunnel objects.

Example:

json
[
  {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "subdomain": "myapp",
    "fqdn": "myapp.example.com",
    "port": 3000,
    "description": "My Web App",
    "type": "app",
    "accessMode": "restricted",
    "agentLabel": "laptop",
    "maxBodySizeMb": 10,
    "enabled": true,
    "createdAt": "2024-03-14T10:30:00.000Z"
  },
  {
    "id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
    "subdomain": "api",
    "fqdn": "api.example.com",
    "port": 8080,
    "description": "API Server",
    "type": "app",
    "accessMode": "authenticated",
    "agentLabel": "build-server",
    "maxBodySizeMb": 100,
    "enabled": true,
    "createdAt": "2024-03-14T11:00:00.000Z"
  }
]

Operations:

  • readTunnels() — returns the array, or [] if the file does not exist
  • writeTunnels(tunnels) — atomic write of the full array

Read returns [] for missing files (ENOENT), enabling the system to start with no tunnels defined.

Tunnel lifecycle:

  1. POST /api/tunnels — issues TLS cert, writes nginx vhost, adds the tunnel object (with its agentLabel)
  2. PATCH /api/tunnels/:id — moves the tunnel to another agent, re-renders its vhost (access mode, body limit), or enables/disables it
  3. DELETE /api/tunnels/:id — removes nginx vhost, removes the tunnel object
  4. On all: the full array is written atomically, then Chisel is synced — chisel-users is re-rendered from the new state (each agent may bind only its enabled tunnels' ports). Chisel reloads the file itself; it is restarted only when a grant or password was withdrawn. The authfile is derived state; tunnels.json and chisel-credentials.json are the sources of truth
  5. DELETE /api/certs/agent/:label (revocation) — the agent's app and plugin tunnels lose their agentLabel (unassigned), and its agent-<label> panel tunnel is removed
  6. At startup (background, fail closed): an ownerless agent-<label> panel tunnel is bound to the agent it names when that agent is active; other ownerless tunnels (from versions before tunnel ownership) are bound to the sole active agent, or left unassigned when there are several. Then the pinned Chisel binary, the unit and the authfile are re-established — and Chisel is stopped if they cannot be

Every one of these workflows runs under the single tunnel lock (withTunnelLock in @lamalibre/lamaste/server), with its validation inside the lock.

Site State (sites.json) ​

Stored at /etc/lamalibre/lamaste/sites.json. An array of site objects.

Example:

json
[
  {
    "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
    "name": "blog",
    "fqdn": "blog.example.com",
    "type": "managed",
    "spaMode": false,
    "autheliaProtected": false,
    "rootPath": "/var/www/lamaste/b2c3d4e5-f6a7-8901-bcde-f12345678901",
    "createdAt": "2024-03-14T12:00:00.000Z"
  }
]

Operations:

  • readSites() — returns the array, or [] if the file does not exist
  • writeSites(sites) — atomic write of the full array

Same atomic write pattern and ENOENT handling as tunnels.

Custom-domain sites may also carry aliases (hostnames redirected with a 301 to the site's fqdn); see the Sites API. The full field list is in Config Files.

YAML Writes for Authelia (users.yml) ​

Authelia reads its user database from /etc/authelia/users.yml at runtime. Lamaste writes this file when creating, updating, or deleting users.

Format:

yaml
users:
  admin:
    displayname: admin
    password: $2b$12$... # bcrypt hash
    email: admin@lamaste.local
    groups:
      - admins
  alice:
    displayname: alice
    password: $2b$12$...
    email: alice@example.com
    groups:
      - admins

Write pattern:

The Panel Server writes Authelia's files itself, without sudo (writeAutheliaFile() in packages/core/lib/src/server/authelia.ts): /etc/authelia is owned by lamaste with group lamaste-authelia and the setgid bit (2770), so the panel can create files there and every new file belongs to Authelia's group.

  1. Writes a temp file in /etc/authelia/ (open(..., 'wx')), sets its mode, fsync()s it
  2. rename()s it over the destination — atomic on the same filesystem, so Authelia never reads a partial file
  3. The mode decides Authelia's access: users.yml 0660 (Authelia rewrites it when a user changes a password), configuration.yml 0640, .secrets.json 0600 (the panel only)

Critical invariant: After writing users.yml, the Authelia service must be restarted to pick up changes. Authelia does not watch the file for changes. The Panel Server calls reloadAuthelia() (which runs systemctl restart authelia) after every user modification.

Safety rule: The Panel Server prevents deleting the last user in the Authelia database. Without at least one user, no one could authenticate to access tunneled apps.

Storage Server Registry (storage-config.json) ​

The storage system manages S3-compatible object storage servers and their bindings to plugins. Storage credentials (access key, secret key) are encrypted at rest using AES-256-GCM with scrypt key derivation.

Encryption scheme:

  1. A 32-byte master key is generated once and stored at /etc/lamalibre/lamaste/storage-master.key (mode 0600)
  2. For each credential, a random 16-byte salt is generated
  3. The master key is passed through scrypt (N=16384, r=8, p=1) with the salt to derive a 32-byte encryption key
  4. The credential is encrypted with AES-256-GCM using a random 12-byte IV
  5. The output is packed as [salt (16)] [iv (12)] [authTag (16)] [ciphertext (...)] and base64-encoded

State file (storage-config.json):

json
{
  "servers": [
    {
      "id": "uuid",
      "label": "my-storage",
      "provider": "digitalocean",
      "region": "fra1",
      "bucket": "my-bucket",
      "endpoint": "https://fra1.digitaloceanspaces.com",
      "accessKeyEncrypted": "<base64-encoded encrypted credential>",
      "secretKeyEncrypted": "<base64-encoded encrypted credential>",
      "registeredAt": "2026-03-30T10:00:00.000Z"
    }
  ],
  "bindings": [
    {
      "pluginName": "sync",
      "storageServerId": "uuid",
      "boundAt": "2026-03-30T10:05:00.000Z"
    }
  ]
}

Concurrency: All read-modify-write operations are serialized via a promise-chain mutex. Writes use the same atomic pattern (temp file, fsync, rename).

Config Path Resolution ​

Different config files are resolved through different mechanisms:

FileResolution
panel.jsonLAMALIBRE_LAMASTE_CONFIG env var → dev/panel.json (dev) → /etc/lamalibre/lamaste/panel.json (prod)
tunnels.jsonLAMALIBRE_LAMASTE_STATE_DIR env var → /etc/lamalibre/lamaste + /tunnels.json
sites.jsonLAMALIBRE_LAMASTE_STATE_DIR env var → /etc/lamalibre/lamaste + /sites.json
invitations.jsonLAMALIBRE_LAMASTE_STATE_DIR env var → /etc/lamalibre/lamaste + /invitations.json
users.ymlHardcoded: /etc/authelia/users.yml
PKI filesLAMALIBRE_LAMASTE_PKI_DIR env var → /etc/lamalibre/lamaste/pki

Environment variables allow overriding paths for development and testing without modifying code.

File Permissions ​

FileModeOwnerRationale
panel.json0600lamaste:lamasteContains sensitive config, owner-only access
tunnels.json0600lamaste:lamasteWritten by Panel Server
sites.json0600lamaste:lamasteWritten by Panel Server
chisel-credentials.json0600lamaste:lamastePer-agent Chisel passwords
chisel-users0640lamaste:lamaste-chiselChisel authfile (read by the Chisel process, group lamaste-chisel)
chisel-sentinel0600lamaste:lamasteSentinel Chisel user password
invitations.json0600lamaste:lamasteWritten by Panel Server
storage-config.json0600lamaste:lamasteStorage registry (credentials AES-256-GCM encrypted)
storage-master.key0600lamaste:lamaste32-byte master key for storage encryption
groups.json0600lamaste:lamasteLamaste group definitions and membership
access-grants.json0600lamaste:lamasteGeneric access grants (principal → resource)
gatekeeper.json0600lamaste:lamasteGatekeeper settings (cache TTL, logging)
access-request-log.json0600lamaste:lamasteOptional denied access log
pki/ca.key0600lamaste:lamasteCA private key — most sensitive file
pki/ca.crt0644lamaste:lamasteCA cert — needs to be readable by nginx
pki/client.key0600lamaste:lamasteClient private key
pki/client.crt0644lamaste:lamasteClient cert
pki/client.p120600lamaste:lamastePKCS12 bundle with private key
pki/.p12-password0600lamaste:lamastePassword for PKCS12 bundle
users.yml0660lamaste:lamaste-autheliaContains bcrypt password hashes; Authelia rewrites it
configuration.yml0640lamaste:lamaste-autheliaContains JWT and session secrets; Authelia reads it
.secrets.json0600lamaste:lamaste-autheliaEncryption keys; the panel only

The PKI directory belongs to lamaste because the panel is the CA (it signs agent certificates itself); the installer creates it as root and hands it over, and every redeploy re-applies that ownership. Authelia's files belong to lamaste with the lamaste-authelia group, so the panel reads and writes them directly and Authelia (running as lamaste-authelia) reads them through its group. Nothing here is read or written with sudo.

Concurrency Safety ​

The Panel Server is a single-process Node.js application, but a single-threaded event loop does not serialize asynchronous workflows: two requests that each read tunnels.json, await a certificate or an nginx test, and then write it back would interleave, and both could pass the same uniqueness check. Serialization is therefore explicit:

Tunnel state is guarded by one promise-chain lock, withTunnelLock in @lamalibre/lamaste/server's tunnels.ts. Every tunnel workflow — create, delete, toggle, assign, reconfigure, the combined PATCH /api/tunnels/:id, releasing a revoked agent's tunnels, and startup reconciliation — takes it for its whole duration, and does its validation (subdomain and port uniqueness, reserved ports, agent checks, authorization against the current entry) inside it.

Chisel authfile renders are serialized by a promise-chain mutex in chisel-users.ts, keyed by the credentials file, around every render (enroll, revoke, rotate, tunnel change). The daemon's syncChisel() renders after the tunnel state is written and restarts Chisel (systemctl try-restart) only when the render withdrew something.

This ensures that concurrent tunnel or credential operations never interleave state or authfile writes.

Key Files ​

FileRole
packages/lamaste-serverd/src/lib/storage.jsStorage server registry, plugin bindings, AES-256-GCM encryption
packages/lamaste-gatekeeper/src/lib/groups.tsLamaste group read/write with atomic writes
packages/lamaste-gatekeeper/src/lib/grants.tsAccess grant read/write with atomic writes
packages/lamaste-serverd/src/lib/config.jsConfig loading, Zod validation, atomic updates
packages/lamaste-serverd/src/lib/state.jstunnels.json + sites.json atomic read/write
packages/core/lib/src/server/authelia.tsusers.yml / configuration.yml read and atomic write (no sudo)
packages/lamaste-serverd/src/lib/files.jsStatic site file operations
packages/create-lamaste/src/tasks/panel.jsInitial panel.json creation
packages/create-lamaste/src/tasks/mtls.jsInitial PKI file creation

Design Decisions ​

Why JSON instead of SQLite? ​

SQLite would work, but adds a binary dependency and ~1 MB of RAM for the database engine. JSON files can be read with cat, edited with any text editor, and backed up with cp. At the current scale (tens of entries), JSON parsing is effectively free. The atomic write pattern provides the same crash-safety guarantees as SQLite's WAL journal.

Why not watch files for changes? ​

File watching (via fs.watch or inotify) adds complexity and platform-specific behavior. Since all state modifications go through the Panel Server API, the in-memory cache is always consistent with the disk. Authelia is the exception — it reads users.yml independently — which is why a restart is required after modifications.

Why structuredClone for getConfig? ​

Without cloning, getConfig() would return a reference to the cached config object. Any modification to the returned object (even accidental) would corrupt the cache. structuredClone creates a deep copy, making the config effectively immutable from the caller's perspective. This is a small performance cost that prevents a class of subtle bugs.

Why fsync before rename? ​

On Linux, writeFile may return before data is flushed to the physical disk (the data may be in the kernel's page cache). If power is lost at this point, the temp file could be empty or corrupted. fd.sync() forces the data to disk, ensuring the rename always produces a valid file. This is the standard pattern for durable writes in database systems.

Why YAML for Authelia users instead of JSON? ​

Authelia's file-based authentication backend expects YAML format. This is an external constraint, not a choice. The Panel Server uses the js-yaml library for serialization and reads the file directly (it owns it).

Released under the PolyForm Noncommercial License 1.0.0