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.
This commit is contained in:
Juan Denis committed 2026-09-24 00:50:02 -04:00
1 parent 230d0d8b1a
commit 45bc3c70bd
24 files changed
+843 -95

No files matched your search

+27
View File
@@ -20,6 +20,33 @@ release history; historical `agent-v*` tags are not panel releases.
## [Unreleased]
### Security
- Require a one-time setup code to create the first administrator, so reaching
a freshly installed panel first is no longer enough to claim it. The
installer prints the code, the panel logs it at startup, and
`serverkit setup-code` shows it again; it stops working once the admin exists.
Unattended installs can set `SERVERKIT_ADMIN_EMAIL`/`SERVERKIT_ADMIN_PASSWORD`
to create the admin directly, and automation can pin `SERVERKIT_SETUP_CODE`.
### Fixed
- Importing a Git repository that has a `docker-compose.yml` now deploys the
compose project — every service it declares — instead of building only its
Dockerfile, which started the web container without its database.
- "Deploy latest" on a Docker app from Git now builds the configured branch's
latest commit; it rebuilt the checkout already on disk.
- A push webhook for a Docker app now rebuilds and restarts it; it only pulled
the code, so the old containers kept serving.
- Switching a Git app to another branch works on imported apps (they are
cloned single-branch, so the new branch was never fetched).
- Redeploying a compose app on a host with the legacy `docker-compose` v1 no
longer fails the preflight by mistaking the app's own ports for a conflict.
- An uploaded archive holding a single file (such as a lone
`docker-compose.yml`) extracted to an empty directory.
- `serverkit create-admin`, `reset-password`, `unlock-user`, `make-admin`,
`activate-user` and `deactivate-user` ignored their flags and always prompted.
### Changed
- Let the template catalog certification test point at an external registry
+10
View File
@@ -312,6 +312,16 @@ def create_app(config_name=None):
import logging
logging.getLogger(__name__).warning(f'Host snapshot skipped: {e}')
# A panel with no account yet logs its one-time setup code, so the
# owner can read it from the service log (setup_code_service).
if not app.config.get('TESTING'):
try:
from app.services import setup_code_service
setup_code_service.ensure()
except Exception as e:
import logging
logging.getLogger(__name__).warning(f'Setup code unavailable: {e}')
# Seed bundled flagship extensions (D4) — WordPress ships installed by
# default on every panel (fresh and upgrade) unless the user uninstalled
# it. Done BEFORE load_all_plugins so the loader registers the seeded
+17 -1
View File
@@ -837,6 +837,20 @@ def create_app_from_repository():
_ws_id = WorkspaceService.ensure_default_workspace().id
project_id, environment_id = _resolve_project_env(data, _ws_id)
# A repository whose manifest is a compose file deploys as that compose
# project — every service it declares (a database next to the web app),
# exactly like an uploaded archive. Building only the Dockerfile it also
# contains used to start the web container without its database. An
# explicit non-compose choice from the caller still wins.
compose_file = None
if (manifest_strategy == 'docker_compose' and app_type in ('auto', 'docker')
and build_method in ('auto', 'dockerfile')):
compose_file = next((m.get('file') for m in manifest.get('manifests') or []
if m.get('type') == 'docker_compose'), None)
managed_by = 'docker_compose' if compose_file else None
if compose_file:
resolved_app_type = 'docker'
app = Application(
name=name,
app_type=resolved_app_type,
@@ -844,10 +858,12 @@ def create_app_from_repository():
root_path=app_path,
user_id=user.id,
port=port,
compose_file=compose_file,
managed_by=managed_by,
buildpack_type=buildpack_type,
buildpack_plan=json.dumps(buildpack_plan) if buildpack_plan else None,
buildpack_overrides=json.dumps(buildpack_overrides) if buildpack_overrides else None,
ingress_plane=_resolve_ingress_plane(data, resolved_app_type),
ingress_plane=_resolve_ingress_plane(data, resolved_app_type, managed_by),
project_id=project_id,
environment_id=environment_id,
)
+19
View File
@@ -41,9 +41,17 @@ def get_setup_status():
from app.services.migration_service import MigrationService
migration_status = MigrationService.get_status()
# Never the code itself: only whether the first registration must carry
# it. Asking also mints and logs it if this panel has none yet.
from app.services import setup_code_service
setup_code_required = setup_code_service.required()
if setup_code_required:
setup_code_service.ensure()
return jsonify({
'needs_setup': needs_setup,
'registration_enabled': registration_enabled,
'setup_code_required': setup_code_required,
'sso_providers': sso_providers,
'password_login_enabled': password_login_enabled,
# Pre-auth branding (read by the login page before any authed call).
@@ -96,6 +104,15 @@ def register():
if not all([email, username, password]):
raise ValidationError('Missing required fields', code='auth.missing_fields')
# The first account becomes the administrator: reaching a fresh panel
# first must not be enough to own it (setup_code_service).
from app.services import setup_code_service
if is_first_user and not setup_code_service.verify(data.get('setup_code')):
logger.warning(f"First-user registration refused: wrong or missing setup code. IP: {request.remote_addr}")
raise PermissionDeniedError(
'Enter the setup code for this server. Run `serverkit setup-code` on the server to see it.',
code='auth.setup_code_invalid')
if User.query.filter(func.lower(User.email) == func.lower(email)).first():
raise ConflictError('This email or username is unavailable', code='auth.identity_unavailable')
@@ -126,6 +143,8 @@ def register():
db.session.add(user)
db.session.commit()
if is_first_user:
setup_code_service.consume()
# Mark invitation accepted
if invitation:
@@ -400,7 +400,9 @@ def published_host_ports(project_path: str, compose_file: str = None) -> Optiona
port check rather than assume the ports are free.
"""
try:
containers = DockerService.compose_ps(project_path, compose_file)
# strict: a failed listing must read as "unknown", not "owns nothing" —
# otherwise every redeploy flags the app's own ports as taken.
containers = DockerService.compose_ps(project_path, compose_file, strict=True)
except Exception as exc: # pragma: no cover - defensive
logger.debug('compose ps failed for %s: %s', project_path, exc)
return None
+112 -56
View File
@@ -118,65 +118,69 @@ class DeploymentService:
app.status = 'deploying'
db.session.commit()
# Step 1: Build
deployment.status = 'building'
deployment.build_started_at = datetime.utcnow()
db.session.commit()
# A Docker app deployed from Git builds its branch's latest commit:
# "Deploy latest" and push webhooks both end up here, and building
# the stale checkout redeployed old code. Non-Docker apps pull in
# _deploy_traditional instead.
if app.app_type == 'docker':
pull_result = cls._pull_latest(app, log_callback)
if not pull_result.get('success'):
return cls._fail(app, deployment, pull_result.get('error') or 'Git pull failed')
if log_callback:
log_callback(f"Starting build for {app.name}...")
build_result = BuildService.build(
app_id,
no_cache=no_cache,
log_callback=log_callback
)
deployment.build_completed_at = datetime.utcnow()
if not build_result.get('success'):
deployment.status = 'failed'
deployment.error_message = build_result.get('error', 'Build failed')
app.status = 'error'
if cls._uses_compose(app):
# The whole compose project: build, validate and pull while
# the current release keeps serving, then switch over.
deployment.status = 'building'
deployment.build_started_at = datetime.utcnow()
db.session.commit()
return {
'success': False,
'error': deployment.error_message,
'deployment': deployment.to_dict()
}
# Store build artifacts
if build_result.get('image_tag'):
deployment.image_tag = build_result['image_tag']
if build_result.get('build_log'):
# Store build log path for later retrieval
log_dir = os.path.join(paths.BUILD_LOG_DIR, str(app_id))
log_file = f"build-{deployment.created_at.isoformat().replace(':', '-')}.json"
deployment.build_log_path = os.path.join(log_dir, log_file)
db.session.commit()
# Step 2: Deploy
deployment.status = 'deploying'
deployment.deploy_started_at = datetime.utcnow()
db.session.commit()
if log_callback:
log_callback("Build successful, starting deployment...")
deploy_result = cls._deploy_application(app, deployment, log_callback)
if not deploy_result.get('success'):
deployment.status = 'failed'
deployment.error_message = deploy_result.get('error', 'Deploy failed')
app.status = 'error'
deploy_result = cls._deploy_compose(app, deployment, log_callback)
deployment.build_completed_at = datetime.utcnow()
if not deploy_result.get('success'):
return cls._fail(app, deployment, deploy_result.get('error') or 'Deploy failed')
else:
# Step 1: Build
deployment.status = 'building'
deployment.build_started_at = datetime.utcnow()
db.session.commit()
return {
'success': False,
'error': deployment.error_message,
'deployment': deployment.to_dict()
}
if log_callback:
log_callback(f"Starting build for {app.name}...")
build_result = BuildService.build(
app_id,
no_cache=no_cache,
log_callback=log_callback
)
deployment.build_completed_at = datetime.utcnow()
if not build_result.get('success'):
return cls._fail(app, deployment, build_result.get('error', 'Build failed'))
# Store build artifacts
if build_result.get('image_tag'):
deployment.image_tag = build_result['image_tag']
if build_result.get('build_log'):
# Store build log path for later retrieval
log_dir = os.path.join(paths.BUILD_LOG_DIR, str(app_id))
log_file = f"build-{deployment.created_at.isoformat().replace(':', '-')}.json"
deployment.build_log_path = os.path.join(log_dir, log_file)
db.session.commit()
# Step 2: Deploy
deployment.status = 'deploying'
deployment.deploy_started_at = datetime.utcnow()
db.session.commit()
if log_callback:
log_callback("Build successful, starting deployment...")
deploy_result = cls._deploy_application(app, deployment, log_callback)
if not deploy_result.get('success'):
return cls._fail(app, deployment, deploy_result.get('error', 'Deploy failed'))
# Mark previous live deployment as rolled_back
current = Deployment.get_current(app_id)
@@ -230,6 +234,58 @@ class DeploymentService:
'deployment': deployment.to_dict()
}
@classmethod
def _fail(cls, app: Application, deployment: Deployment, error: str) -> Dict:
deployment.status = 'failed'
deployment.error_message = error
app.status = 'error'
db.session.commit()
return {'success': False, 'error': error, 'deployment': deployment.to_dict()}
@staticmethod
def _uses_compose(app: Application) -> bool:
"""A local app whose runtime is its own compose project."""
return bool(app.compose_file and app.root_path and not app.server_id)
@classmethod
def _pull_latest(cls, app: Application, log_callback: Callable[[str], None] = None) -> Dict:
"""Check out the configured branch's latest commit, if deployed from Git."""
deploy_config = GitService.get_app_config(app.id)
if not deploy_config:
return {'success': True, 'skipped': True}
branch = deploy_config.get('branch') or 'main'
if log_callback:
log_callback(f"Pulling latest changes from {branch}...")
return GitService.pull_changes(app.root_path, branch)
@classmethod
def _deploy_compose(cls, app: Application, deployment: Deployment,
log_callback: Callable[[str], None] = None) -> Dict:
"""Deploy an app that is a compose project (every service it declares).
The preflight validates the file, builds and pulls every image and
checks ports while the current release is still serving; `up -d
--build` then recreates only the services whose image or config
changed, so an unchanged database keeps running through a deploy.
"""
from app.services import deploy_preflight_service as preflight
checks = preflight.preflight_compose_project(app.root_path, app.compose_file, log=log_callback)
if not checks.ok:
# Nothing was stopped: the previous release is still serving.
return {'success': False, 'error': checks.error, 'preflight': checks.to_dict()}
deployment.status = 'deploying'
deployment.deploy_started_at = datetime.utcnow()
db.session.commit()
if log_callback:
log_callback(f"Starting compose project {app.compose_file}...")
result = DockerService.compose_up(app.root_path, detach=True, build=True,
compose_file=app.compose_file)
if not result.get('success'):
return {'success': False, 'error': result.get('error') or 'docker compose up failed'}
return {'success': True}
@classmethod
def _deploy_application(cls, app: Application, deployment: Deployment,
log_callback: Callable[[str], None] = None) -> Dict:
+11 -3
View File
@@ -1209,7 +1209,7 @@ class DockerService:
return {'success': False, 'error': str(e)}
@classmethod
def compose_ps(cls, project_path, compose_file=None):
def compose_ps(cls, project_path, compose_file=None, strict=False):
"""List Docker Compose services.
Handles multiple output formats from docker compose ps --format json:
@@ -1217,7 +1217,10 @@ class DockerService:
- JSON Array: Single line with array of objects
- Mixed: Warning messages (time=...) mixed with JSON
Returns a list of container dictionaries.
Returns a list of container dictionaries. A failed listing (e.g. the
legacy docker-compose v1, which has no ``--format json``) is also an
empty list unless ``strict``, which returns None instead — for callers
that must not mistake "could not tell" for "no containers".
"""
try:
result = cls.run_compose(
@@ -1243,10 +1246,15 @@ class DockerService:
except json.JSONDecodeError:
continue
return containers
# docker-compose v1 answers `--format json` with its usage text on
# stderr and exit code 0: "success" with nothing listed.
unsupported = 'usage:' in (result.get('stderr') or '').lower()
if strict and (not result['success'] or unsupported):
return None
return []
except Exception as e:
logger.error(f"Failed to list compose services: {e}")
return []
return None if strict else []
@classmethod
def compose_logs(cls, project_path, service=None, tail=100, compose_file=None):
+30 -6
View File
@@ -161,14 +161,19 @@ class GitService:
return {'success': False, 'error': ref_error}
try:
# Fetch first
fetch_cmd = git_argv('-C', app_path, 'fetch', '--all')
run_checked(fetch_cmd, timeout=60, env=git_env())
# Get current branch if not specified
if not branch:
branch = cls._git_out(app_path, 'rev-parse', '--abbrev-ref', 'HEAD') or 'main'
# Fetch that branch by explicit refspec: imports clone with
# --single-branch, so `fetch --all` never brought in a branch the
# app was switched to later, and the reset below failed.
fetch_cmd = git_argv('-C', app_path, 'fetch', 'origin',
f'+refs/heads/{branch}:refs/remotes/origin/{branch}')
fetched = run_checked(fetch_cmd, timeout=60, env=git_env())
if not fetched['success']:
return {'success': False, 'error': fetched['error'] or f'Could not fetch {branch}'}
# Reset to remote branch (force pull)
reset_cmd = git_argv('-C', app_path, 'reset', '--hard', f'origin/{branch}')
result = run_checked(reset_cmd, timeout=60, env=git_env())
@@ -214,12 +219,31 @@ class GitService:
return None
@classmethod
def deploy(cls, app_id: int, force: bool = False) -> Dict:
def deploy(cls, app_id: int, force: bool = False, trigger: str = 'manual') -> Dict:
"""Deploy an application from Git."""
app_config = cls.get_app_config(app_id)
if not app_config:
return {'success': False, 'error': 'Deployment not configured'}
# A Docker app's code only reaches users once its containers are
# rebuilt, so it deploys through the same job as "Deploy latest"
# (which pulls the configured branch itself). Pulling alone — what a
# push webhook used to do — left the old containers serving.
from app.models.application import Application
app = Application.query_active().filter_by(id=app_id).first()
if app and app.app_type == 'docker':
# Pull now as well: a webhook re-reads serverkit.yaml right after
# this returns and must see the new commit. The job's own pull is
# then a no-op.
pulled = cls.pull_changes(app_config['app_path'], app_config.get('branch', 'main'))
if not pulled['success']:
return {'success': False, 'error': f"Pull failed: {pulled['error']}"}
from app.services.deployment_job_service import DeploymentJobService
queued = DeploymentJobService.enqueue_app_deploy(app, trigger=trigger)
if not queued.get('success'):
return {'success': False, 'error': queued.get('error') or 'Failed to queue deployment'}
return {'success': True, 'message': 'Deployment queued', 'deploy_job_id': queued.get('job_id')}
app_path = app_config['app_path']
branch = app_config.get('branch', 'main')
@@ -454,7 +478,7 @@ class GitService:
pass
# Trigger deployment
result = cls.deploy(app_id)
result = cls.deploy(app_id, trigger='webhook')
# Push-to-reconfigure: re-read serverkit.yaml at the new commit and
# reconcile (plan 17 #17). Best-effort — never affects the deploy result.
@@ -0,0 +1,99 @@
"""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()
+5 -2
View File
@@ -84,10 +84,13 @@ def extract_version(app_dir, zippath, version):
with zipfile.ZipFile(zippath, 'r') as zf:
# If every top-level entry shares a single folder, strip it so the
# version directory contains the app files directly.
# version directory contains the app files directly. Only a real
# folder counts: an archive holding one file (a lone
# docker-compose.yml) has that file as its common path, and treating
# it as a folder used to strip it to nothing.
names = zf.namelist()
top = os.path.commonpath(names).split('/')[0] if names else ''
has_top_folder = top and all(n.startswith(top + '/') or n == top for n in names)
has_top_folder = top and all(n.startswith(top + '/') for n in names)
if has_top_folder:
for info in zf.infolist():
+15
View File
@@ -87,11 +87,26 @@ def create_admin(email, username, password):
# Mark setup as complete so the UI doesn't show the setup wizard
from app.services.settings_service import SettingsService
from app.services import setup_code_service
SettingsService.complete_setup(user_id=user.id)
setup_code_service.consume()
click.echo(click.style(f'Admin user "{username}" created successfully!', fg='green'))
@cli.command()
def setup_code():
"""Show the one-time code that creating the first admin requires."""
app = create_app()
with app.app_context():
from app.services import setup_code_service
code = setup_code_service.ensure()
if not code:
click.echo('This panel already has an account; no setup code is needed.')
return
click.echo(code)
@cli.command()
@click.option('--email', prompt=True, help='User email address')
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='New password')
+24 -3
View File
@@ -70,7 +70,7 @@ def _stub_compose(monkeypatch, *, valid=True, config=None, build=True,
calls.append('pull') or
({'success': True} if pull else {'success': False, 'error': 'manifest unknown'}))))
monkeypatch.setattr(DockerService, 'compose_ps', classmethod(
lambda cls, path, compose_file=None: (calls.append('ps') or (ps if ps is not None else []))))
lambda cls, path, compose_file=None, **kw: (calls.append('ps') or (ps if ps is not None else []))))
monkeypatch.setattr(DockerService, 'compose_down', classmethod(
lambda cls, path, **kw: calls.append('DOWN') or {'success': True}))
monkeypatch.setattr(DockerService, 'compose_up', classmethod(
@@ -209,7 +209,7 @@ def test_unknown_port_ownership_skips_the_check_rather_than_aborting(app, monkey
_stub_compose(monkeypatch,
config={'services': {'web': {'image': 'nginx', 'ports': ['8080:80']}}})
monkeypatch.setattr(DockerService, 'compose_ps', classmethod(
lambda cls, path, compose_file=None: (_ for _ in ()).throw(RuntimeError('no docker'))))
lambda cls, path, compose_file=None, **kw: (_ for _ in ()).throw(RuntimeError('no docker'))))
monkeypatch.setattr(ManifestApplyService, '_port_bound',
staticmethod(lambda port: True))
@@ -233,7 +233,7 @@ def test_short_port_syntax_parsing(spec, expected):
def test_legacy_ports_string_is_understood(app, monkeypatch):
"""Older compose ps output carries `Ports` as a string, not `Publishers`."""
monkeypatch.setattr(DockerService, 'compose_ps', classmethod(
lambda cls, path, compose_file=None: [
lambda cls, path, compose_file=None, **kw: [
{'Ports': '0.0.0.0:8080->80/tcp, :::8080->80/tcp'},
{'Ports': '127.0.0.1:5432->5432/tcp'},
]))
@@ -397,3 +397,24 @@ def test_deploy_docker_pulls_before_it_stops(app, monkeypatch, tmp_path):
assert result['success'] is True
assert calls == ['pull', 'STOP', 'REMOVE', 'RUN']
def test_an_unreadable_project_is_unknown_not_empty(app, monkeypatch):
"""docker-compose v1 has no `ps --format json`: the listing fails. That
must skip the port check, not flag the app's own ports as taken."""
from app.services import deploy_preflight_service as preflight
monkeypatch.setattr(DockerService, 'run_compose', classmethod(
lambda cls, cmd, cwd=None, **kw: {'success': False, 'output': '', 'error': 'unknown flag: --format'}))
assert DockerService.compose_ps('/srv/app') == []
assert DockerService.compose_ps('/srv/app', strict=True) is None
assert preflight.published_host_ports('/srv/app') is None
# v1 exits 0 with its usage text instead of failing outright.
monkeypatch.setattr(DockerService, 'run_compose', classmethod(
lambda cls, cmd, cwd=None, **kw: {'success': True, 'output': '', 'returncode': 0,
'stderr': 'List containers.\n\nUsage: ps [options]'}))
assert DockerService.compose_ps('/srv/app', strict=True) is None
assert preflight.published_host_ports('/srv/app') is None
# A project that genuinely has no containers is still an empty set.
monkeypatch.setattr(DockerService, 'run_compose', classmethod(
lambda cls, cmd, cwd=None, **kw: {'success': True, 'output': '', 'stderr': '', 'returncode': 0}))
assert preflight.published_host_ports('/srv/app') == set()
+215
View File
@@ -0,0 +1,215 @@
"""Git-based Docker apps: a compose repository deploys as its compose project,
and every deploy of a Git app builds the configured branch's latest commit.
Found by a deploy benchmark (2026-09-24): importing a repo with
both a Dockerfile and a docker-compose.yml started the web container without
its database; "Deploy latest" rebuilt the old checkout; a push webhook pulled
code but never rebuilt the containers.
"""
import subprocess
import pytest
from app import db
from app.models.application import Application
from app.services import deploy_preflight_service as preflight
from app.services.build_service import BuildService
from app.services.deployment_service import DeploymentService
from app.services.docker_service import DockerService
from app.services.git_service import GitService
# --- import ---------------------------------------------------------------
def _mock_import(monkeypatch, strategy, manifests):
from app.services.repository_manifest_service import RepositoryManifestService
from app.services.manifest_persistence_service import ManifestPersistenceService
from app.services.deployment_job_service import DeploymentJobService
monkeypatch.setattr(GitService, 'clone_repository', classmethod(lambda cls, *a, **k: {'success': True}))
monkeypatch.setattr(RepositoryManifestService, 'analyze_path', classmethod(lambda cls, *a, **k: {
'recommended': {'app_type': 'docker', 'build_method': 'dockerfile', 'port': 3000},
'strategy': strategy, 'manifests': manifests}))
monkeypatch.setattr(BuildService, 'detect_build_method', classmethod(lambda cls, *a, **k: {
'build_method': 'dockerfile', 'has_dockerfile': True, 'has_docker_compose': True}))
monkeypatch.setattr(GitService, 'configure_deployment', classmethod(lambda cls, *a, **k: {
'success': True, 'webhook_url': 'http://panel/hooks/1'}))
monkeypatch.setattr(BuildService, 'configure_build', classmethod(lambda cls, *a, **k: {
'success': True, 'config': {'build_method': 'dockerfile'}}))
monkeypatch.setattr(ManifestPersistenceService, 'apply_import', classmethod(lambda cls, *a, **k: None))
monkeypatch.setattr(DeploymentJobService, 'enqueue_app_deploy',
classmethod(lambda cls, app, **k: {'success': True, 'job_id': 'dj-1'}))
COMPOSE = [{'type': 'docker_compose', 'file': 'docker-compose.yml', 'label': 'Docker Compose', 'summary': ''},
{'type': 'dockerfile', 'file': 'Dockerfile', 'label': 'Dockerfile', 'summary': ''}]
def test_a_compose_repository_imports_as_its_compose_project(app, client, auth_headers, monkeypatch):
_mock_import(monkeypatch, 'docker_compose', COMPOSE)
res = client.post('/api/v1/apps/from-repository', headers=auth_headers, json={
'name': 'bench-app', 'repo_url': 'https://github.com/acme/bench.git'})
assert res.status_code == 201
created = Application.query.filter_by(name='bench-app').first()
assert created.compose_file == 'docker-compose.yml'
assert created.managed_by == 'docker_compose'
assert created.app_type == 'docker'
@pytest.mark.parametrize('choice', [{'build_method': 'nixpacks'}, {'app_type': 'static'}])
def test_an_explicit_non_compose_choice_still_wins(app, client, auth_headers, monkeypatch, choice):
_mock_import(monkeypatch, 'docker_compose', COMPOSE)
res = client.post('/api/v1/apps/from-repository', headers=auth_headers, json={
'name': 'chosen-app', 'repo_url': 'https://github.com/acme/bench.git', **choice})
assert res.status_code == 201
created = Application.query.filter_by(name='chosen-app').first()
assert created.compose_file is None and created.managed_by is None
def test_a_dockerfile_only_repository_is_unchanged(app, client, auth_headers, monkeypatch):
_mock_import(monkeypatch, 'dockerfile', [COMPOSE[1]])
res = client.post('/api/v1/apps/from-repository', headers=auth_headers, json={
'name': 'image-app', 'repo_url': 'https://github.com/acme/image.git'})
assert res.status_code == 201
assert Application.query.filter_by(name='image-app').first().compose_file is None
# --- deploy ---------------------------------------------------------------
def _app(compose=True, app_type='docker'):
row = Application(name='bench', app_type=app_type, status='running', root_path='/srv/bench', user_id=1,
compose_file='docker-compose.yml' if compose else None,
managed_by='docker_compose' if compose else None)
db.session.add(row)
db.session.commit()
return row
@pytest.fixture
def calls(monkeypatch):
"""Record the order of every step the deploy takes; nothing touches Docker or Git."""
seen = []
monkeypatch.setattr(GitService, 'get_commit_info', classmethod(lambda cls, *a: None))
monkeypatch.setattr(GitService, 'get_app_config', classmethod(lambda cls, app_id: {'branch': 'next'}))
monkeypatch.setattr(GitService, 'pull_changes', classmethod(
lambda cls, path, branch=None: seen.append(('pull', branch)) or {'success': True}))
monkeypatch.setattr(preflight, 'preflight_compose_project',
lambda path, compose_file=None, **k: seen.append(('preflight', compose_file))
or preflight.PreflightResult())
monkeypatch.setattr(DockerService, 'compose_up', classmethod(
lambda cls, path, detach=True, build=False, compose_file=None:
seen.append(('up', build, compose_file)) or {'success': True}))
monkeypatch.setattr(BuildService, 'build', classmethod(
lambda cls, app_id, **k: seen.append(('image_build',)) or {'success': True, 'image_tag': 'bench:1'}))
monkeypatch.setattr(DeploymentService, '_deploy_application', classmethod(
lambda cls, app, deployment, log=None: seen.append(('run_image',)) or {'success': True}))
return seen
def test_a_compose_app_pulls_then_deploys_every_service(app, calls):
row = _app()
result = DeploymentService.deploy(row.id)
assert result['success'], result
# Latest commit first, then the whole project: never the single-image path.
assert calls == [('pull', 'next'), ('preflight', 'docker-compose.yml'), ('up', True, 'docker-compose.yml')]
assert db.session.get(Application, row.id).status == 'running'
def test_a_failed_compose_preflight_stops_nothing(app, calls, monkeypatch):
failing = preflight.PreflightResult()
failing.add(preflight.PreflightFinding(kind=preflight.KIND_COMPOSE_INVALID, message='bad compose'))
monkeypatch.setattr(preflight, 'preflight_compose_project', lambda *a, **k: failing)
row = _app()
result = DeploymentService.deploy(row.id)
assert not result['success'] and 'bad compose' in result['error']
assert ('up', True, 'docker-compose.yml') not in calls
def test_an_image_app_from_git_builds_the_latest_commit(app, calls):
row = _app(compose=False)
assert DeploymentService.deploy(row.id)['success']
assert calls == [('pull', 'next'), ('image_build',), ('run_image',)]
def test_a_failed_pull_fails_before_building(app, calls, monkeypatch):
monkeypatch.setattr(GitService, 'pull_changes',
classmethod(lambda cls, *a, **k: {'success': False, 'error': 'network down'}))
row = _app(compose=False)
result = DeploymentService.deploy(row.id)
assert not result['success'] and result['error'] == 'network down'
assert ('image_build',) not in calls
def test_an_app_without_git_is_not_pulled(app, calls, monkeypatch):
monkeypatch.setattr(GitService, 'get_app_config', classmethod(lambda cls, app_id: None))
row = _app()
assert DeploymentService.deploy(row.id)['success']
assert not any(step[0] == 'pull' for step in calls)
# --- push webhook / Deploy tab -------------------------------------------
def test_a_docker_app_webhook_deploy_queues_a_rebuild(app, monkeypatch):
from app.services.deployment_job_service import DeploymentJobService
row = _app()
queued, pulled = [], []
monkeypatch.setattr(GitService, 'get_app_config',
classmethod(lambda cls, app_id: {'app_path': '/srv/bench', 'branch': 'main'}))
monkeypatch.setattr(GitService, 'pull_changes',
classmethod(lambda cls, path, branch=None: pulled.append(branch) or {'success': True}))
monkeypatch.setattr(DeploymentJobService, 'enqueue_app_deploy', classmethod(
lambda cls, app, trigger='install', **k: queued.append((app.id, trigger)) or
{'success': True, 'job_id': 'dj-9'}))
result = GitService.deploy(row.id, trigger='webhook')
assert result == {'success': True, 'message': 'Deployment queued', 'deploy_job_id': 'dj-9'}
# Pulled synchronously too, so serverkit.yaml re-sync reads the new commit.
assert pulled == ['main'] and queued == [(row.id, 'webhook')]
def test_a_non_docker_app_keeps_its_pull_deploy(app, monkeypatch):
from app.services.deployment_job_service import DeploymentJobService
row = _app(compose=False, app_type='flask')
monkeypatch.setattr(GitService, 'get_app_config',
classmethod(lambda cls, app_id: {'app_path': '/srv/bench', 'branch': 'main'}))
monkeypatch.setattr(GitService, 'pull_changes',
classmethod(lambda cls, *a, **k: {'success': True, 'commit': None}))
monkeypatch.setattr(GitService, 'get_config', classmethod(lambda cls: {'apps': {str(row.id): {}}}))
monkeypatch.setattr(GitService, 'save_config', classmethod(lambda cls, config: None))
monkeypatch.setattr(GitService, '_log_deployment', classmethod(lambda cls, log: None))
monkeypatch.setattr(DeploymentJobService, 'enqueue_app_deploy',
classmethod(lambda cls, *a, **k: pytest.fail('non-Docker apps are not rebuilt')))
assert GitService.deploy(row.id)['message'] == 'Deployment completed successfully'
# --- branch switch on a single-branch clone ------------------------------
def _git(*args, cwd=None):
subprocess.run(['git', *args], cwd=cwd, check=True, capture_output=True)
@pytest.mark.skipif(subprocess.run(['git', '--version'], capture_output=True).returncode != 0,
reason='git is not installed')
def test_pull_reaches_a_branch_the_single_branch_clone_never_fetched(tmp_path, monkeypatch):
# The panel pins remote transports to https/ssh; this test's origin is a
# local directory, so allow file:// here only. The git commands are real.
import os
import app.services.git_service as git_module
monkeypatch.setattr(git_module, 'git_argv', lambda *args: ['git', *args])
monkeypatch.setattr(git_module, 'git_env', lambda: {**os.environ, 'GIT_ALLOW_PROTOCOL': 'file'})
origin, work = tmp_path / 'origin', tmp_path / 'work'
_git('init', '-q', '-b', 'main', str(origin))
for key, value in (('user.email', 't@example.com'), ('user.name', 't')):
_git('config', key, value, cwd=origin)
(origin / 'VERSION').write_text('1.0.0\n')
_git('add', '.', cwd=origin)
_git('commit', '-q', '-m', 'main', cwd=origin)
_git('checkout', '-q', '-b', 'next', cwd=origin)
(origin / 'VERSION').write_text('1.0.1\n')
_git('commit', '-q', '-am', 'next', cwd=origin)
_git('checkout', '-q', 'main', cwd=origin)
# How imports clone: one branch only.
_git('clone', '-q', '--branch', 'main', '--single-branch', str(origin), str(work))
result = GitService.pull_changes(str(work), 'next')
assert result['success'], result
assert (work / 'VERSION').read_text() == '1.0.1\n'
+93
View File
@@ -0,0 +1,93 @@
"""First-run setup code: the first registration (which becomes the admin)
needs the code only someone with access to the server can read."""
import pytest
from app.services import setup_code_service
def _register(client, **extra):
body = {'email': 'owner@example.com', 'username': 'owner', 'password': 'correct-horse-9', **extra}
return client.post('/api/v1/auth/register', json=body)
@pytest.fixture(autouse=True)
def _no_pinned_code(monkeypatch):
monkeypatch.delenv(setup_code_service.ENV_OVERRIDE, raising=False)
@pytest.fixture(autouse=True)
def _fresh_rate_limit():
# /register allows 3 attempts a minute; each test starts with its own budget.
from app import limiter
limiter.reset()
yield
limiter.reset()
def test_setup_status_says_a_code_is_needed_without_revealing_it(client, app):
response = client.get('/api/v1/auth/setup-status')
assert response.status_code == 200
assert response.json['setup_code_required'] is True
with app.app_context():
code = setup_code_service.ensure()
assert code and code not in response.get_data(as_text=True)
@pytest.mark.parametrize('submitted', [None, '', 'AAAA-BBBB-CCCC'])
def test_first_registration_without_the_right_code_is_refused(client, app, submitted):
extra = {} if submitted is None else {'setup_code': submitted}
response = _register(client, **extra)
assert response.status_code == 403
assert response.json['code'] == 'auth.setup_code_invalid'
from app.models import User
with app.app_context():
assert User.query.count() == 0
def test_the_code_claims_the_admin_once_and_is_then_consumed(client, app):
with app.app_context():
code = setup_code_service.ensure()
# Typed by hand: case, spaces and missing dashes do not matter.
response = _register(client, setup_code=' ' + code.lower().replace('-', ' '))
assert response.status_code == 201
assert response.json['user']['role'] == 'admin'
from app.models import SystemSettings
with app.app_context():
assert SystemSettings.query.filter_by(key=setup_code_service.SETTING_KEY).first() is None
assert setup_code_service.ensure() is None
assert setup_code_service.required() is False
assert client.get('/api/v1/auth/setup-status').json['setup_code_required'] is False
# The same code cannot open a second account.
again = _register(client, email='late@example.com', username='late', setup_code=code)
assert again.status_code == 403
def test_the_code_survives_a_process_with_another_encryption_key(app, monkeypatch):
# The service mints it; `serverkit setup-code` (another process, maybe
# another environment) must print the same code, not ciphertext.
from cryptography.fernet import Fernet
with app.app_context():
monkeypatch.setenv('SERVERKIT_ENCRYPTION_KEY', Fernet.generate_key().decode())
first = setup_code_service.ensure()
monkeypatch.setenv('SERVERKIT_ENCRYPTION_KEY', Fernet.generate_key().decode())
assert setup_code_service.ensure() == first
assert setup_code_service.verify(first)
assert setup_code_service.SETUP_CODE_PATTERN.fullmatch(first)
def test_an_existing_account_needs_no_code(app, auth_headers):
with app.app_context():
assert setup_code_service.required() is False
assert setup_code_service.ensure() is None
assert setup_code_service.verify('anything') is False
def test_a_pinned_code_is_used_only_when_long_enough(app, monkeypatch):
with app.app_context():
monkeypatch.setenv(setup_code_service.ENV_OVERRIDE, 'release-lab-pinned-code')
assert setup_code_service.ensure() == 'release-lab-pinned-code'
assert setup_code_service.verify('RELEASE LAB PINNED CODE')
# Too short to be safe: ignored, so the generated code still applies.
monkeypatch.setenv(setup_code_service.ENV_OVERRIDE, 'abc123')
generated = setup_code_service.ensure()
assert generated != 'abc123' and len(generated.replace('-', '')) == 12
+35
View File
@@ -0,0 +1,35 @@
"""extract_version: what an uploaded archive becomes on disk."""
import zipfile
from app.services.upload_service import extract_version
def _zip(path, entries):
with zipfile.ZipFile(path, 'w') as archive:
for name, content in entries.items():
archive.writestr(name, content)
return str(path)
def test_single_file_archive_keeps_its_file(tmp_path):
# Found by a deploy benchmark (2026-09-24): a zip
# holding only docker-compose.yml extracted to an empty directory.
archive = _zip(tmp_path / 'app.zip', {'docker-compose.yml': 'services: {}\n'})
version_dir = extract_version(str(tmp_path / 'app'), archive, 1)
assert (tmp_path / 'app' / 'versions' / 'v1' / 'docker-compose.yml').read_text() == 'services: {}\n'
assert version_dir.endswith('v1')
def test_wrapper_folder_is_stripped(tmp_path):
archive = _zip(tmp_path / 'app.zip', {'site/': '', 'site/index.html': 'hi', 'site/css/a.css': 'x'})
extract_version(str(tmp_path / 'app'), archive, 1)
root = tmp_path / 'app' / 'versions' / 'v1'
assert (root / 'index.html').read_text() == 'hi'
assert (root / 'css' / 'a.css').read_text() == 'x'
def test_flat_archive_extracts_as_is(tmp_path):
archive = _zip(tmp_path / 'app.zip', {'index.html': 'hi', 'docker-compose.yml': 'services: {}\n'})
extract_version(str(tmp_path / 'app'), archive, 1)
root = tmp_path / 'app' / 'versions' / 'v1'
assert sorted(p.name for p in root.iterdir()) == ['docker-compose.yml', 'index.html']
+4
View File
@@ -81,9 +81,13 @@ serverkit uninstall
### User Management
```bash
# Show the setup code the first admin registration needs
serverkit setup-code
# Create admin user
serverkit create-admin
# Prompts for: email, username, password
# (or: serverkit create-admin --email a@b.com --username admin --password '...')
# Reset a user's password
serverkit reset-password
+12 -2
View File
@@ -265,7 +265,10 @@ docker compose ps
Open your browser and navigate to `http://your-server-ip:5000` (override the
published port with `SERVERKIT_HTTP_PORT` in `.env`).
Create your admin account on first visit.
Create your admin account on first visit. The setup page asks for the
first-run setup code, which the container logs at startup
(`docker logs serverkit | grep 'setup code'`) and prints on demand:
`docker exec serverkit python cli.py setup-code`.
The container speaks plain HTTP only — it has no certificates and no HTTPS
listener. For TLS, terminate it in a reverse proxy in front of the container:
@@ -716,9 +719,16 @@ TLS handshake fails. Install the root in its system trust store
### 1. Create Admin Account
1. Open ServerKit in your browser
2. Click "Register" to create your admin account
2. Enter the **setup code** printed at the end of the install
(`serverkit setup-code` shows it again), then create your admin account
3. The first registered user automatically becomes admin
The setup code stops anyone who reaches a freshly installed panel before you
from claiming it; it stops working once the admin exists. For unattended
installs, set `SERVERKIT_ADMIN_EMAIL` and `SERVERKIT_ADMIN_PASSWORD` (and
optionally `SERVERKIT_ADMIN_USERNAME`) when running the installer to create
the admin directly — no setup code involved.
### 2. Update ClamAV Definitions
```bash
+2 -1
View File
@@ -35,7 +35,8 @@ Choose your preferred installation method:
After installation:
1. Access ServerKit at `http://your-server-ip`
2. Create your admin account (first user becomes admin)
2. Create your admin account with the setup code the installer printed
(`serverkit setup-code` shows it again; the first user becomes admin)
3. [Enable Two-Factor Authentication](INSTALLATION.md#enable-two-factor-authentication)
4. [Configure Notifications](INSTALLATION.md#configure-notification-webhooks)
@@ -3,6 +3,7 @@ import { useAuth } from '../../contexts/useAuth.js';
import { Info } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { FormField } from '../FormField';
import { Label } from '@/components/ui/label';
import { useTranslation } from 'react-i18next';
import { Button as SharedButton } from '@/components/ui/button';
@@ -13,9 +14,10 @@ const SetupStepAccount = ({ onComplete }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [setupCode, setSetupCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register, login, registrationEnabled } = useAuth();
const { register, login, registrationEnabled, setupCodeRequired } = useAuth();
// If users already exist (e.g. admin created via CLI), show login form instead
const showLogin = !registrationEnabled;
@@ -37,7 +39,7 @@ const SetupStepAccount = ({ onComplete }) => {
setLoading(true);
try {
await register(email, username, password);
await register(email, username, password, undefined, setupCodeRequired ? setupCode : undefined);
onComplete({ email, username });
} catch (err) {
setError(err.message || 'Failed to create admin account');
@@ -133,6 +135,26 @@ const SetupStepAccount = ({ onComplete }) => {
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleRegister}>
{setupCodeRequired && (
<FormField
htmlFor="setupCode"
label={t('app.setupStepAccount.setupCode', 'Setup code')}
hint={t('app.setupStepAccount.setupCodeHint', 'Shown at the end of the install. On the server, run: serverkit setup-code')}
>
<Input
type="text"
id="setupCode"
value={setupCode}
onChange={(e) => setSetupCode(e.target.value)}
placeholder={t('app.setupStepAccount.setupCodePlaceholder', 'XXXX-XXXX-XXXX')}
autoComplete="off"
spellCheck={false}
required
autoFocus
/>
</FormField>
)}
<div className="form-group">
<Label htmlFor="email">{t('app.setupStepAccount.adminEmail', 'Admin Email')}</Label>
<Input
@@ -142,7 +164,7 @@ const SetupStepAccount = ({ onComplete }) => {
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
required
autoFocus
autoFocus={!setupCodeRequired}
/>
</div>
+9 -2
View File
@@ -10,6 +10,8 @@ export function AuthProvider({ children }) {
const [setupStatus, setSetupStatus] = useState({
needsSetup: false,
registrationEnabled: false,
// A fresh panel's first registration needs the server's setup code.
setupCodeRequired: false,
ssoProviders: [],
passwordLoginEnabled: true,
// Build-time default until the pre-auth fetch resolves (avoids a title flash
@@ -48,6 +50,7 @@ export function AuthProvider({ children }) {
setSetupStatus({
needsSetup: status.needs_setup,
registrationEnabled: status.registration_enabled,
setupCodeRequired: !!status.setup_code_required,
ssoProviders: status.sso_providers || [],
passwordLoginEnabled: status.password_login_enabled !== false,
panelTitle: status.panel_title || import.meta.env.VITE_PANEL_TITLE || 'ServerKit',
@@ -73,6 +76,7 @@ export function AuthProvider({ children }) {
...prev,
needsSetup: true,
registrationEnabled: true,
setupCodeRequired: true,
checked: true
}));
await checkAuth();
@@ -96,6 +100,7 @@ export function AuthProvider({ children }) {
setSetupStatus({
needsSetup: status.needs_setup,
registrationEnabled: status.registration_enabled,
setupCodeRequired: !!status.setup_code_required,
ssoProviders: status.sso_providers || [],
passwordLoginEnabled: status.password_login_enabled !== false,
panelTitle: status.panel_title || import.meta.env.VITE_PANEL_TITLE || 'ServerKit',
@@ -116,8 +121,8 @@ export function AuthProvider({ children }) {
return data;
}
async function register(email, username, password, inviteToken) {
const data = await api.register(email, username, password, inviteToken);
async function register(email, username, password, inviteToken, setupCode) {
const data = await api.register(email, username, password, inviteToken, setupCode);
setUser(data.user);
return data;
}
@@ -141,6 +146,7 @@ export function AuthProvider({ children }) {
...prev,
needsSetup: false,
registrationEnabled: false,
setupCodeRequired: false,
checked: true
}));
}
@@ -196,6 +202,7 @@ export function AuthProvider({ children }) {
needsMigration: setupStatus.needsMigration,
migrationInfo: setupStatus.migrationInfo,
registrationEnabled: setupStatus.registrationEnabled,
setupCodeRequired: setupStatus.setupCodeRequired,
ssoProviders: setupStatus.ssoProviders,
passwordLoginEnabled: setupStatus.passwordLoginEnabled,
panelTitle: setupStatus.panelTitle,
+3
View File
@@ -5334,6 +5334,9 @@
"enterYourPassword": "Enter your password",
"itLooksLikeAnAdminAccount": "It looks like an admin account was created via the CLI. Sign in with those credentials to finish setting up your server.",
"setUpTheAdministratorAccountFor": "Set up the administrator account for your server.",
"setupCode": "Setup code",
"setupCodeHint": "Shown at the end of the install. On the server, run: serverkit setup-code",
"setupCodePlaceholder": "XXXX-XXXX-XXXX",
"signIn": "Sign In",
"thisIsYourFirstTimeUsing": "This is your first time using ServerKit. Create an administrator account to get started. This account will have full access to manage your server."
},
+2 -1
View File
@@ -16,9 +16,10 @@ export async function login(email, password) {
return data;
}
export async function register(email, username, password, inviteToken) {
export async function register(email, username, password, inviteToken, setupCode) {
const body = { email, username, password };
if (inviteToken) body.invite_token = inviteToken;
if (setupCode) body.setup_code = setupCode;
const data = await this.request('/auth/register', {
method: 'POST',
body,
+43 -1
View File
@@ -11,6 +11,13 @@
# BUILD_FROM_SOURCE=1 force a source build even when a release exists
# SERVERKIT_SKIP_SSL=1 run on plain HTTP (no HTTPS / no certbot attempt)
#
# The first account becomes the administrator, so a fresh panel asks for a
# one-time setup code (printed at the end; `serverkit setup-code` repeats it).
# Unattended installs can create the admin instead and skip the code:
#
# SERVERKIT_ADMIN_EMAIL=... with SERVERKIT_ADMIN_PASSWORD (8+ chars);
# SERVERKIT_ADMIN_USERNAME=... optional, defaults to the email's local part
#
# Running your own reverse proxy (Caddy / Traefik / nginx) in front of the panel:
#
# SERVERKIT_EXTERNAL_PROXY=1 your proxy terminates TLS; ours must not
@@ -2585,6 +2592,32 @@ snapshot_existing() {
# ---------------------------------------------------------------------------
# Closing summary
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# First administrator
# ---------------------------------------------------------------------------
# Until an account exists, whoever registers first owns the panel, so the
# panel requires a one-time setup code for that registration. Show it to the
# person running the installer, or skip it entirely when they named the admin.
claim_first_admin() {
SETUP_CODE=""
ADMIN_CREATED=""
local backend="$INSTALL_DIR/backend" python="$VENV_DIR/bin/python"
[ -x "$python" ] || return 0
if [ -n "${SERVERKIT_ADMIN_EMAIL:-}" ] && [ -n "${SERVERKIT_ADMIN_PASSWORD:-}" ]; then
local username="${SERVERKIT_ADMIN_USERNAME:-${SERVERKIT_ADMIN_EMAIL%%@*}}"
if (cd "$backend" && "$python" cli.py create-admin --email "$SERVERKIT_ADMIN_EMAIL" \
--username "$username" --password "$SERVERKIT_ADMIN_PASSWORD") >/dev/null 2>&1; then
ADMIN_CREATED="$SERVERKIT_ADMIN_EMAIL"
else
warn "Could not create the admin from SERVERKIT_ADMIN_EMAIL (it may already exist)."
fi
fi
# Last line only: creating the app can log first. Anything that is not a
# code means the panel already has an account (an upgrade, or the above).
SETUP_CODE=$(cd "$backend" && "$python" cli.py setup-code 2>/dev/null | tail -n 1 \
| grep -E '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' || true)
}
print_outro() {
local ip
ip=$(curl -sf --max-time 5 https://api.ipify.org 2>/dev/null || \
@@ -2617,7 +2650,15 @@ print_outro() {
printf ' certbot, or set SERVERKIT_SKIP_SSL=1 to suppress this warning.\n'
fi
printf '\n %sFirst step%s create an admin user\n\n' "$BLD" "$RST"
if [ -n "${ADMIN_CREATED:-}" ]; then
printf '\n %sAdmin%s %s (from SERVERKIT_ADMIN_EMAIL) - sign in\n\n' "$BLD" "$RST" "$ADMIN_CREATED"
elif [ -n "${SETUP_CODE:-}" ]; then
printf '\n %sSetup code%s %s%s%s\n' "$BLD" "$RST" "$BLD" "$SETUP_CODE" "$RST"
printf ' The setup page asks for it before creating the admin.\n'
printf ' Show it again: serverkit setup-code\n\n'
else
printf '\n'
fi
printf ' %sCLI%s serverkit status\n' "$BLD" "$RST"
printf ' serverkit create-admin\n'
@@ -2801,6 +2842,7 @@ main() {
set -e
ping_telemetry
claim_first_admin
print_outro
}
+28 -13
View File
@@ -285,6 +285,7 @@ cmd_help() {
echo "User Management:"
echo " create-admin Create a new admin user"
echo " reset-password Reset a user's password"
echo " setup-code Show the code that creating the first admin requires"
echo " unlock-user Unlock a locked user account"
echo " list-users List all users"
echo " make-admin Promote user to admin"
@@ -371,7 +372,7 @@ cmd_completion() {
_serverkit() {
local cur commands
cur="${COMP_WORDS[COMP_CWORD]}"
commands="help start stop restart status logs update branch version check-update uninstall create-admin reset-password unlock-user list-users make-admin deactivate-user activate-user init-db migrate-db db-migrate db-status db-history backup-db restore-db disk cleanup-apps factory-reset generate-keys config setup-proxy create-app deploy-template list-servers deployment-status list-apps start-app stop-app app-logs add-site list-sites remove-site doctor manifest services apps repair login-url support-bundle connect panel-status panel-doctor completion"
commands="help start stop restart status logs update branch version check-update uninstall create-admin reset-password setup-code unlock-user list-users make-admin deactivate-user activate-user init-db migrate-db db-migrate db-status db-history backup-db restore-db disk cleanup-apps factory-reset generate-keys config setup-proxy create-app deploy-template list-servers deployment-status list-apps start-app stop-app app-logs add-site list-sites remove-site doctor manifest services apps repair login-url support-bundle connect panel-status panel-doctor completion"
if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
@@ -733,18 +734,23 @@ cmd_uninstall() {
cmd_create_admin() {
check_installed
print_info "Creating admin user..."
run_cli create-admin
run_cli create-admin "$@"
}
cmd_reset_password() {
check_installed
print_info "Resetting password..."
run_cli reset-password
run_cli reset-password "$@"
}
cmd_setup_code() {
check_installed
run_cli setup-code
}
cmd_unlock_user() {
check_installed
run_cli unlock-user
run_cli unlock-user "$@"
}
cmd_list_users() {
@@ -754,17 +760,17 @@ cmd_list_users() {
cmd_make_admin() {
check_installed
run_cli make-admin
run_cli make-admin "$@"
}
cmd_deactivate_user() {
check_installed
run_cli deactivate-user
run_cli deactivate-user "$@"
}
cmd_activate_user() {
check_installed
run_cli activate-user
run_cli activate-user "$@"
}
cmd_init_db() {
@@ -1587,26 +1593,35 @@ case "${1:-help}" in
cmd_uninstall "$@"
;;
create-admin)
cmd_create_admin
shift
cmd_create_admin "$@"
;;
reset-password)
cmd_reset_password
shift
cmd_reset_password "$@"
;;
setup-code)
cmd_setup_code
;;
unlock-user)
cmd_unlock_user
shift
cmd_unlock_user "$@"
;;
list-users)
shift
cmd_list_users "$@"
;;
make-admin)
cmd_make_admin
shift
cmd_make_admin "$@"
;;
deactivate-user)
cmd_deactivate_user
shift
cmd_deactivate_user "$@"
;;
activate-user)
cmd_activate_user
shift
cmd_activate_user "$@"
;;
init-db)
cmd_init_db