Skip to content

Security model

The panel is designed for one VPS shared by apps you don’t fully trust. A hostile app must not be able to read or change another app’s files, processes, network traffic or systemd state, and must not be able to reach the panel’s database or Redis with the panel’s credentials.

Read this before you host apps for other people on a panel node, expose a database publicly, or hand out API tokens.

The sandbox reduces what an app can do. It isn’t a virtual machine boundary. If tenants need kernel-level separation, or must not share egress or disk, give them separate servers. Known gaps are listed at the end of this page.

Layer Mechanism
Identity Each app runs as its own system user app_<6 chars> with no login shell and no home directory.
Filesystem /srv/apps/<user>/ is mode 0750. The unit sets ProtectSystem=strict, ProtectHome, PrivateTmp, PrivateDevices and PrivateMounts. The app can write only to shared/, tmp/ and logs/. current/ and releases/ are read-only.
Mandatory access The agent generates an AppArmor profile per app, which denies by default everything outside the app’s directory and the runtime’s libraries.
Kernel surface NoNewPrivileges, RestrictNamespaces, RestrictSUIDSGID, LockPersonality, RestrictRealtime, ProtectKernel*, ProtectProc=invisible, SystemCallArchitectures=native.
Syscalls A seccomp filter of @system-service minus @privileged @resources @mount. Blocked calls fail with EPERM.
Resources cgroups v2 MemoryMax, CPUQuota, TasksMax, LimitNOFILE. An OOM kill stays inside the app’s cgroup.
Network RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK. A web app is reached only through Nginx. A tcp app’s port is opened in ufw only when you ask for it.

The API server runs as the unprivileged gisila user with its own systemd sandbox. It reads logs through the systemd-journal group and can’t escalate. Only the worker (root) runs gisila-agent. The agent:

  • Validates every --user, --work-dir, hostname, runtime and source type against strict patterns before it calls useradd, systemctl, certbot or ufw.
  • Rejects control characters in anything it renders into a unit, supervisor config or env file, so a newline can’t inject directives such as ExecStartPre=+…. Env var names must be plain identifiers.
  • Starts builds and console commands with a minimal environment, so nothing from /etc/gisila/.env (the JWT secret, database passwords) reaches an app user’s processes.
  • Only clones remote git URLs (https://, ssh://, git:// or user@host:path). file:// and local paths are refused, so one app can’t clone another app’s tree.
Credential Use Notes
Session JWT The web UI HS256, signed with JWT_SECRET, lifetime JWT_EXPIRE_DAYS (default 14). The panel won’t start without a real secret.
API token gsl_… CLI, CI, AI assistants Shown once, stored as a SHA-256 hash, optional expiry, revocable. Send it as Authorization: Bearer gsl_… or X-API-Token: gsl_….

Revocation. Changing your password, turning on 2FA, a superuser resetting your 2FA, being deactivated, or calling POST /auth/sessions/revoke invalidates every session JWT issued before that moment. The calls that keep you signed in return a fresh token. API tokens are revoked separately.

Two-factor authentication. Optional TOTP (RFC 6238, 6 digits, 30 s) plus ten single-use recovery codes. A correct password for an account with 2FA returns a 5-minute challenge, which you exchange at /auth/login/mfa. Codes can’t be replayed.

Brute force. Failed sign-ins are counted in Redis: 10 per account per address, 50 per account and 100 per address in 15 minutes, after which sign-in answers 429 until the window ends. Unknown emails take as long to reject as known ones. Passwords are hashed with Argon2id.

Client addresses. Proxy headers are trusted only from loopback (the host’s Nginx), so rate limits, the login throttle and the audit log can’t be fed a forged address.

Env var values and deployment env snapshots are encrypted with AES-256-GCM using /etc/gisila/secret.key. Back that key up with the database: without it those values can’t be read, and the panel refuses to start if the key is missing while encrypted values exist. Secret env vars (isSecret: true) are never returned by the API after they’re saved.

  • App resources need a role in the app’s team (see Teams, projects and apps). Log WebSockets apply the same rule as the REST routes.
  • Node-wide infrastructure (services, database instances, storage, mail, firewall) is superuser-only. Linking a bucket to an app is too, because it puts the bucket’s keys into env vars that any developer can read.
  • An app can only use an SSH deploy key owned by the user who configures it.
  • Every successful state-changing request is written to the audit log (GET /audit/).

Firewall changes made from the panel can’t block SSH, 80, 443 or your own address, and roll back automatically unless your browser confirms them within 5 minutes. See Change the firewall safely.

  • Apps share the host’s outbound network. There is no per-app egress policy.
  • There are no disk quotas.
  • Session JWTs are long-lived (revocable, but there are no refresh tokens).

Check what an app is allowed to do on the host:

Terminal window
systemctl cat gisila-app_ab12cd.service # sandbox and limits
sudo aa-status | grep gisila-app_ab12cd # AppArmor profile loaded
systemd-analyze security gisila-app_ab12cd.service