Files
Juan Denis 45bc3c70bd Guard first admin with a setup code, fix Git deploys
A fresh panel no longer lets whoever reaches it first become admin:
registering the first account needs a one-time code the installer
prints (`serverkit setup-code` repeats it). SERVERKIT_ADMIN_EMAIL and
SERVERKIT_ADMIN_PASSWORD create the admin unattended instead.

Git apps now deploy what users expect: a repository with a compose
file deploys every service it declares, "Deploy latest" builds the
branch's newest commit, and push webhooks rebuild the containers.
Also fixes branch switching on single-branch clones, uploads of a
lone file, compose v1 port preflight, and CLI commands that ignored
their flags.
2026-09-24 00:50:02 -04:00

100 lines
3.3 KiB
Python

"""First-run setup code: reaching a fresh panel first is not enough to own it.
Until an account exists, the first registration becomes the administrator.
Without a secret, whoever loads a freshly installed panel's setup page first
(a scanner, not its owner) could claim it. The panel therefore mints a one-time
code while it has no users, shows it only to someone with access to the
server (the installer's summary, the service log, `serverkit setup-code`), and
requires it for that first registration. Creating the first admin by any route
consumes it.
`SERVERKIT_SETUP_CODE` pins the code for automation (release tests, local
tooling); it must be at least MIN_LENGTH characters so it cannot weaken the
generated default.
Stored in plain text on purpose, not in SettingsService.SECRET_KEYS: it only
exists while the panel has no account, whoever can read the database already
controls the server, and encryption at rest let a process with a different
key (the CLI vs the service) read ciphertext back as the "code" — locking the
owner out of their own panel.
"""
import hmac
import logging
import os
import re
import secrets
logger = logging.getLogger(__name__)
SETTING_KEY = 'setup_code'
ENV_OVERRIDE = 'SERVERKIT_SETUP_CODE'
# No 0/O or 1/I/L: the code is read off a terminal and typed by hand.
ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
GROUPS, GROUP_SIZE = 3, 4 # 12 characters, ~59 bits
MIN_LENGTH = 12
SETUP_CODE_PATTERN = re.compile(r'[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}')
_announced = False
def _has_users():
from app.models.user import User
return User.query.count() > 0
def _normalize(value):
return ''.join(ch for ch in str(value or '').upper() if ch.isalnum())
def _generate():
raw = ''.join(secrets.choice(ALPHABET) for _ in range(GROUPS * GROUP_SIZE))
return '-'.join(raw[i:i + GROUP_SIZE] for i in range(0, len(raw), GROUP_SIZE))
def _pinned():
value = os.environ.get(ENV_OVERRIDE, '').strip()
if value and len(_normalize(value)) < MIN_LENGTH:
logger.warning('%s ignored: it must have at least %d letters/digits', ENV_OVERRIDE, MIN_LENGTH)
return None
return value or None
def required():
"""True while the panel has no account and registration needs the code."""
return not _has_users()
def ensure():
"""The active code, minting and announcing it if the panel has no users.
Returns None once an account exists: there is nothing left to protect.
"""
global _announced
if _has_users():
return None
from app.services.settings_service import SettingsService
code = _pinned() or SettingsService.get(SETTING_KEY)
if not code:
code = _generate()
SettingsService.set(SETTING_KEY, code)
if not _announced:
_announced = True
logger.warning('First-run setup code: %s (show it again with: serverkit setup-code)', code)
return code
def verify(submitted):
"""Constant-time comparison, ignoring case, spaces and dashes."""
expected = ensure()
if not expected:
return False
return hmac.compare_digest(_normalize(submitted).encode(), _normalize(expected).encode())
def consume():
"""Forget the code once the first administrator exists."""
from app import db
from app.models.system_settings import SystemSettings
SystemSettings.query.filter_by(key=SETTING_KEY).delete()
db.session.commit()