Security model
What it is
Section titled “What it is”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.
When to use it
Section titled “When to use it”Read this before you host apps for other people on a panel node, expose a database publicly, or hand out API tokens.
When not to use it
Section titled “When not to use it”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.
How it works
Section titled “How it works”Per-app isolation
Section titled “Per-app isolation”| 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. |
Privilege separation
Section titled “Privilege separation”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 callsuseradd,systemctl,certbotorufw. - 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://oruser@host:path).file://and local paths are refused, so one app can’t clone another app’s tree.
Authentication
Section titled “Authentication”| 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.
Secrets at rest
Section titled “Secrets at rest”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.
Authorization
Section titled “Authorization”- 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 safety
Section titled “Firewall safety”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.
Known gaps
Section titled “Known gaps”- 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).
Example
Section titled “Example”Check what an app is allowed to do on the host:
systemctl cat gisila-app_ab12cd.service # sandbox and limitssudo aa-status | grep gisila-app_ab12cd # AppArmor profile loadedsystemd-analyze security gisila-app_ab12cd.service