mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
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:
1 parent
230d0d8b1a
commit
45bc3c70bd
24 files changed
+805
-57
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,6 +118,26 @@ class DeploymentService:
|
||||
app.status = 'deploying'
|
||||
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 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()
|
||||
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()
|
||||
@@ -135,15 +155,7 @@ class DeploymentService:
|
||||
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'
|
||||
db.session.commit()
|
||||
return {
|
||||
'success': False,
|
||||
'error': deployment.error_message,
|
||||
'deployment': deployment.to_dict()
|
||||
}
|
||||
return cls._fail(app, deployment, build_result.get('error', 'Build failed'))
|
||||
|
||||
# Store build artifacts
|
||||
if build_result.get('image_tag'):
|
||||
@@ -168,15 +180,7 @@ class DeploymentService:
|
||||
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'
|
||||
db.session.commit()
|
||||
return {
|
||||
'success': False,
|
||||
'error': deployment.error_message,
|
||||
'deployment': deployment.to_dict()
|
||||
}
|
||||
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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user