ADconsole v1.1.0
CI / PHP 8.2 (push) Failing after 1m40s
CI / PHP 8.3 (push) Failing after 55s
CI / PHP 8.4 (push) Failing after 1m11s
Release image / image (push) Successful in 2m33s

Config backup, a Docker dev/prod split, and a Gitea-built release image.

Added
- /settings -> General -> "Configuration backup": JSON export/import of the
  panel's general settings + attribute definitions
  (App\Services\ConfigBackupService); audited as config.export / config.import.
  Export can leave out the attribute-encryption key. No AD data included.
- docker/compose.dev.yaml: live-reload dev stack that bind-mounts the working
  tree. docker/php/Dockerfile is now multi-stage (base -> dev / prod).
- .github/workflows/release.yml: on every vX.Y.Z tag, builds the prod image and
  pushes gitea.kzbikowski.pl/kzbkowski/adconsole:{X.Y.Z,X.Y,latest}.

Changed
- docker/compose.yaml: self-contained prod image (app code + composer install
  --no-dev baked in via COPY); persistent state in docker/data/{app_storage,db};
  defaults to the released image (ADCONSOLE_VERSION pins a tag, ADCONSOLE_IMAGE
  replaces the ref). Apache now honours public/.htaccess (AllowOverride All).
  Run from the repo root with --env-file .env.
- Repo root now holds only README.md; CHANGELOG / CONTRIBUTING / SECURITY /
  CODE_OF_CONDUCT moved under docs/.

Upgrade notes: docs/upgrading.md#v110--docker-layout. Full log: docs/CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKT9p3bfP8RZesExdYCS52
This commit is contained in:
kzbkowskiandClaude Sonnet 5 committed 2026-09-10 09:13:04 +02:00
1 parent 1126d96271
commit b313c126ce
35 files changed
+1174 -119

No files matched your search

+14 -4
View File
@@ -1,6 +1,9 @@
# Build-context trimming for `docker build` (docker/php/Dockerfile).
# The compose stack bind-mounts the project at runtime, so the image only needs
# the entrypoint; keep the context small.
# Build context for docker/php/Dockerfile (context = repo root).
#
# prod target — COPYs the application in, so keep app code but drop
# everything the image rebuilds or must never ship (vendor/,
# node_modules/, .env, tests/, docs/, host state in ./data).
# dev target — bakes nothing; compose.dev.yaml bind-mounts the repo.
.git
.github
@@ -37,5 +40,12 @@ phpunit.xml
.phpunit.cache
*.md
.dockerignore
# Compose files + local overrides + persistent state — never needed in the image.
docker/compose.yaml
docker/compose.dev.yaml
docker/compose.override.yaml
docker/compose.override.yml
docker/compose.dev.override.yaml
docker/*.template
docker/data
+9 -1
View File
@@ -21,6 +21,13 @@ APP_URL=https://adconsole.example.com
# bare-metal install.
APP_DOMAIN=adconsole.example.com
# Docker image for docker/compose.yaml. By default it pulls the release the
# Gitea workflow published; ADCONSOLE_VERSION pins a tag, ADCONSOLE_IMAGE
# replaces the whole reference (e.g. a private mirror). Ignored on bare metal
# and by `docker compose ... up --build` (which rebuilds locally).
# ADCONSOLE_VERSION=1.1.0
# ADCONSOLE_IMAGE=gitea.kzbikowski.pl/kzbkowski/adconsole:1.1.0
# Interface language: pl | en. FALLBACK is used for missing translation keys.
APP_LOCALE=pl
APP_FALLBACK_LOCALE=en
@@ -48,7 +55,8 @@ DB_USERNAME=adconsole
DB_PASSWORD=
# Only read by docker/compose.yaml when it first initialises the MariaDB
# volume. Changing them later does NOT change an existing database.
# data dir (docker/data/db). Changing them later does NOT change an existing
# database.
DB_ROOT_PASSWORD=
# --- Session / cache / queue --------------------------------------------
+2 -2
View File
@@ -2,8 +2,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/src/branch/main/SECURITY.md
about: Do not file security issues publicly — email the maintainer (see SECURITY.md).
url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/src/branch/main/docs/SECURITY.md
about: Do not file security issues publicly — email the maintainer (see docs/SECURITY.md).
- name: Question / help
url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/issues
about: Deployment and configuration questions — search existing issues first.
+1 -1
View File
@@ -10,7 +10,7 @@
- [ ] **Database migration** included
- [ ] New **`.env` key** — added to `.env.example` and `docs/configuration.md`
- [ ] New user-facing text — added to **both** `lang/pl` and `lang/en`
- [ ] `CHANGELOG.md` updated (Unreleased)
- [ ] `docs/CHANGELOG.md` updated (Unreleased)
## Checks
+63
View File
@@ -0,0 +1,63 @@
name: Release image
# Builds the self-contained production image (docker/php/Dockerfile, target
# `prod`) and pushes it to this Gitea instance's container registry whenever a
# vX.Y.Z tag is pushed. `workflow_dispatch` builds an untagged snapshot.
#
# gitea.kzbikowski.pl/kzbkowski/adconsole:1.1.0
# gitea.kzbikowski.pl/kzbkowski/adconsole:1.1
# gitea.kzbikowski.pl/kzbkowski/adconsole:latest
#
# Needs `packages: write`. The built-in GITHUB_TOKEN works for the repo owner's
# package namespace; set a `REGISTRY_TOKEN` secret if the instance restricts it.
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
packages: write
env:
REGISTRY: gitea.kzbikowski.pl
IMAGE_NAME: ${{ github.repository_owner }}/adconsole
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
- name: Image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
type=sha,enable=${{ github.ref_type != 'tag' }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/php/Dockerfile
target: prod
push: true
provenance: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+4
View File
@@ -32,6 +32,10 @@ Thumbs.db
# Local Docker overrides (ports, extra services) - not for distribution.
/docker/compose.override.yaml
/docker/compose.override.yml
/docker/compose.dev.override.yaml
# Persistent state for the production stack (docker/compose.yaml): DB + storage.
/docker/data/
# AI assistant / editor local state.
/.claude
+1 -1
View File
@@ -38,7 +38,7 @@ dashboard and an org chart. PHP 8.2+. Interface is PL/EN.
| `app/Http/Controllers/Admin/*` | one controller per module (Users, Computers, Groups, Gpo, OrgChart, Dashboard, Audit, Config) |
| `app/Ldap/User.php` | AD user model + attribute map + `toPanelProfile()` |
| `app/Ldap/Rules/MemberOfLoginGroup.php` | login authorisation rule |
| `app/Services/` | `PanelSettingsService` (config, DB-backed), `AttributeCipher`, `LdapAttributeHelper` (UAC bits, FileTime), `OuTreeService` (cached OU tree), `AuditLogger` (writes `panel_audit`; never throws — a log failure is only `report()`ed) |
| `app/Services/` | `PanelSettingsService` (config, DB-backed), `ConfigBackupService` (JSON export/import of the panel config), `AttributeCipher`, `LdapAttributeHelper` (UAC bits, FileTime), `OuTreeService` (cached OU tree), `AuditLogger` (writes `panel_audit`; never throws — a log failure is only `report()`ed) |
| `app/Models/PanelSetting.php` | key→JSON row: general panel config (`panel_settings`) |
| `app/Models/PanelAttribute.php` | one row per configured AD attribute (`panel_attributes`, ordered by `position`) |
| `app/Models/AuditLog.php` | one row per audited panel action (`panel_audit`); written only via `AuditLogger`, read only by `AuditController` |
+2 -2
View File
@@ -13,10 +13,10 @@ from the repository root.
Human-facing documentation (installation, configuration, deployment,
troubleshooting, …) lives in [`../docs/`](../docs/). Contribution rules are in
[`../CONTRIBUTING.md`](../CONTRIBUTING.md).
[`../docs/CONTRIBUTING.md`](../docs/CONTRIBUTING.md).
## Keeping it current
Update `AGENTS.md` whenever you change the architecture, the golden rules, the
top-level layout, or the commands in the dev loop. If you rename or move it,
update the `@`-import in `../CLAUDE.md` and the links in `../CONTRIBUTING.md`.
update the `@`-import in `../CLAUDE.md` and the links in `../docs/CONTRIBUTING.md`.
+23 -11
View File
@@ -24,7 +24,7 @@ is no database copy of your directory to drift out of sync.
PHP 8.2+ host.
> ⚠️ ADconsole performs privileged directory operations (password resets,
> account enable/disable, deletions). Read [`SECURITY.md`](SECURITY.md) and the
> account enable/disable, deletions). Read [`SECURITY.md`](docs/SECURITY.md) and the
> [hardening checklist](docs/deployment.md#hardening-checklist) before exposing it.
---
@@ -64,17 +64,23 @@ git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adc
cp .env.example .env
# edit .env: APP_URL/APP_DOMAIN, DB_* passwords, and the whole LDAP_* / AD_* block
# With an external Traefik proxy (see docker/compose.yaml):
docker compose -f docker/compose.yaml up -d --build
# Production — pulls the released image, external Traefik proxy. Run from the
# repo root and point Compose at the repo-root .env:
docker compose --env-file .env -f docker/compose.yaml up -d
# Or without a proxy — publish a local port instead:
cp docker/compose.override.yaml.template docker/compose.override.yaml
docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
docker compose --env-file .env -f docker/compose.yaml -f docker/compose.override.yaml up -d
# -> http://localhost:8080 (set APP_URL to match)
# Live-reload dev stack (bind-mounts the working tree):
docker compose --env-file .env -f docker/compose.dev.yaml up --build
```
The container entrypoint installs dependencies, generates `APP_KEY`, runs
migrations and warms the caches on first boot. Details and production notes:
`docker/compose.yaml` runs the self-contained image the Gitea release workflow
publishes (`…/adconsole:1.1.0`); `up -d` pulls it, `--build` rebuilds from your
tree. Only `.env` and `docker/data/` (DB + storage) are mounted. The entrypoint
generates `APP_KEY`, runs migrations and warms the caches on first boot. Details:
[`docs/deployment.md`](docs/deployment.md).
## Quick start — bare metal
@@ -148,7 +154,9 @@ public/js/main.js hand-written JS
routes/web.php English URL segments and route names
lang/{pl,en}/ structured translation keys
docs/ installation / AD / deployment / architecture / …
docker/ compose stack + PHP image
docker/compose.yaml production stack (self-contained image + MariaDB)
docker/compose.dev.yaml live-reload dev stack (bind-mounts the working tree)
docker/php/ PHP 8.4 + Apache Dockerfile (base/dev/prod) + entrypoint
```
## Documentation
@@ -164,6 +172,10 @@ Everything lives in [`docs/`](docs/):
[upgrading](docs/upgrading.md) ·
[publishing](docs/publishing.md).
Project meta: [changelog](docs/CHANGELOG.md) ·
[contributing](docs/CONTRIBUTING.md) ·
[security policy](docs/SECURITY.md).
## Roadmap / non-goals
Planned: server-side password-policy validation before write; optional TOTP for
@@ -193,10 +205,10 @@ When you deploy ADconsole your organisation is the **data controller** for the
personal data it reads from AD (names, logins, e-mail, phone, photo, …). You are
responsible for the lawful basis, retention and data-subject rights; restrict
panel access to authorised admins and always use LDAPS/STARTTLS. See the
in-app **About → Legal** page and [`SECURITY.md`](SECURITY.md).
in-app **About → Legal** page and [`SECURITY.md`](docs/SECURITY.md).
## Contributing
Issues and PRs welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) and the
[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). Security reports:
[`SECURITY.md`](SECURITY.md).
Issues and PRs welcome — see [`CONTRIBUTING.md`](docs/CONTRIBUTING.md) and the
[`CODE_OF_CONDUCT.md`](docs/CODE_OF_CONDUCT.md). Security reports:
[`SECURITY.md`](docs/SECURITY.md).
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Wgrywany plik kopii zapasowej konfiguracji jest nieczytelny albo w
* nieobsługiwanym formacie. Łapane w App\Http\Controllers\Admin\ConfigController
* i zamieniane na błąd walidacji pola formularza.
*/
class ConfigBackupException extends RuntimeException {}
@@ -2,9 +2,12 @@
namespace App\Http\Controllers\Admin;
use App\Exceptions\ConfigBackupException;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\ConfigBackupService;
use App\Services\PanelSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -140,4 +143,64 @@ class ConfigController extends Controller
->with('status', __('config.flash.attributes_saved'))
->withFragment('attributes');
}
/**
* Pobranie kopii zapasowej konfiguracji panelu jako plik JSON: ustawienia
* ogólne + definicje atrybutów (bez danych z AD). Parametr `exclude_key=1`
* pomija w pliku klucz szyfrujący atrybuty.
*/
public function export(Request $request, ConfigBackupService $backup): JsonResponse
{
$includeKey = ! $request->boolean('exclude_key');
$payload = $backup->export($includeKey);
$this->audit->log('config.export', [
'context' => [
'attributes' => count($payload['attributes']),
'encryption_key_included' => $payload['encryption_key_included'],
],
]);
$filename = 'adconsole-config-'.now()->format('Ymd-His').'.json';
return response()->json($payload, 200, [
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
'Cache-Control' => 'no-store',
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* Wczytanie kopii zapasowej z pliku JSON. Nadpisuje ustawienia ogólne i/lub
* listę atrybutów zawartością pliku; sekcje nieobecne w pliku zostają bez
* zmian. Błąd formatu wraca jako błąd walidacji pola `config_file`.
*/
public function import(Request $request, ConfigBackupService $backup): RedirectResponse
{
$request->validate([
'config_file' => ['required', 'file', 'max:1024', 'mimes:json,txt'],
]);
$raw = (string) file_get_contents($request->file('config_file')->getRealPath());
$data = json_decode($raw, true);
try {
if (json_last_error() !== JSON_ERROR_NONE) {
throw new ConfigBackupException(__('config.backup.error_not_json'));
}
$result = $backup->import($data);
} catch (ConfigBackupException $e) {
return back()->withErrors(['config_file' => $e->getMessage()]);
}
$this->audit->log('config.import', [
'context' => [
'settings' => $result['settings'],
'attributes' => $result['attributes'],
],
]);
return back()->with('status', __('config.backup.flash_imported'));
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace App\Services;
use App\Exceptions\ConfigBackupException;
/**
* Eksport i import konfiguracji panelu (kopia zapasowa do pobrania):
* - ustawienia ogólne (wiersz 'panel' w `panel_settings`),
* - definicje atrybutów AD i kolumn listy (`panel_attributes`).
*
* Nie dotyka danych z Active Directory ani infrastruktury Laravela
* (sesje / cache / kolejki / dziennik `panel_audit`). Cały odczyt i zapis
* konfiguracji idzie przez PanelSettingsService - ta klasa tylko pakuje go
* do pliku JSON i z powrotem.
*/
class ConfigBackupService
{
/** Znacznik formatu pliku kopii zapasowej (pole `format`). */
public const FORMAT = 'adconsole-config';
/** Najwyższa obsługiwana wersja schematu pliku (pole `version`). */
public const SCHEMA_VERSION = 1;
public function __construct(protected PanelSettingsService $settings) {}
/**
* Buduje strukturę kopii zapasowej gotową do zapisania jako JSON.
*
* @param bool $includeEncryptionKey gdy false, klucz szyfrujący atrybuty
* nie trafia do pliku (bezpieczniejsze
* przy przesyłaniu / archiwizacji).
* @return array<string, mixed>
*/
public function export(bool $includeEncryptionKey = true): array
{
$general = $this->settings->all();
if (! $includeEncryptionKey) {
$general['encryption']['key'] = '';
}
$keyIncluded = ($general['encryption']['key'] ?? '') !== '';
return [
'format' => self::FORMAT,
'version' => self::SCHEMA_VERSION,
'generator' => 'ADconsole',
'exported_at' => now()->toIso8601String(),
'encryption_key_included' => $keyIncluded,
'settings' => $general,
'attributes' => array_map(
static fn (array $a): array => [
'key' => $a['key'],
'label' => $a['label'],
'ldap' => $a['ldap'],
'category' => $a['category'],
'type' => $a['type'],
'regex' => $a['regex'],
'required' => $a['required'],
'help' => $a['help'],
'column' => $a['column'],
],
$this->settings->attributes(),
),
];
}
/**
* Waliduje i wgrywa kopię zapasową. Nadpisuje ustawienia ogólne i/lub
* listę atrybutów tym, co jest w pliku; sekcje nieobecne w pliku zostają
* bez zmian.
*
* @param mixed $data zdekodowana zawartość pliku (oczekiwana: tablica)
* @return array{settings: bool, attributes: int} co zostało wgrane
*
* @throws ConfigBackupException gdy plik jest pusty, w złym formacie albo
* pochodzi z nowszej wersji panelu
*/
public function import(mixed $data): array
{
if (! is_array($data)) {
throw new ConfigBackupException(__('config.backup.error_not_json'));
}
if (($data['format'] ?? null) !== self::FORMAT) {
throw new ConfigBackupException(__('config.backup.error_bad_format'));
}
$version = (int) ($data['version'] ?? 0);
if ($version < 1 || $version > self::SCHEMA_VERSION) {
throw new ConfigBackupException(__('config.backup.error_bad_version'));
}
$hasSettings = isset($data['settings']) && is_array($data['settings']);
$hasAttributes = isset($data['attributes']) && is_array($data['attributes']);
if (! $hasSettings && ! $hasAttributes) {
throw new ConfigBackupException(__('config.backup.error_empty'));
}
$applied = ['settings' => false, 'attributes' => 0];
if ($hasSettings) {
$this->settings->save($this->sanitizeSettings($data['settings']));
$applied['settings'] = true;
}
if ($hasAttributes) {
// saveAttributes() sam normalizuje wiersze, wymusza reguły pozycji
// systemowych / wyliczanych i odtwarza te, których w pliku brak.
$this->settings->saveAttributes($this->sanitizeAttributes($data['attributes']));
$applied['attributes'] = count($this->settings->attributes());
}
return $applied;
}
/**
* Sprowadza dowolne wejście do kształtu ustawień ogólnych: start od
* wbudowanych domyślnych, nadpisanie wyłącznie znanych kluczy, z kontrolą
* typu i zakresu. Odpowiada regułom z ConfigController::update(), żeby plik
* z ręcznie podmienioną wartością nie wprowadził panelu w zły stan.
*
* @param array<string, mixed> $in
* @return array<string, mixed>
*/
protected function sanitizeSettings(array $in): array
{
$d = $this->settings->defaults();
$reset = is_array($in['reset_password'] ?? null) ? $in['reset_password'] : [];
$policy = is_array($in['password_policy'] ?? null) ? $in['password_policy'] : [];
$enc = is_array($in['encryption'] ?? null) ? $in['encryption'] : [];
$audit = is_array($in['audit'] ?? null) ? $in['audit'] : [];
return [
'reset_password' => [
'must_change_password' => filter_var($reset['must_change_password'] ?? $d['reset_password']['must_change_password'], FILTER_VALIDATE_BOOL),
'unlock_account' => filter_var($reset['unlock_account'] ?? $d['reset_password']['unlock_account'], FILTER_VALIDATE_BOOL),
],
'password_policy' => [
'max_age_days' => max(0, min(3650, (int) ($policy['max_age_days'] ?? $d['password_policy']['max_age_days']))),
'generator_length' => max(8, min(128, (int) ($policy['generator_length'] ?? $d['password_policy']['generator_length']))),
'generator_symbols' => filter_var($policy['generator_symbols'] ?? $d['password_policy']['generator_symbols'], FILTER_VALIDATE_BOOL),
],
'encryption' => [
'key' => trim((string) ($enc['key'] ?? '')),
],
'audit' => [
'require_reason' => filter_var($audit['require_reason'] ?? $d['audit']['require_reason'], FILTER_VALIDATE_BOOL),
],
];
}
/**
* Sprowadza listę atrybutów z pliku do wierszy przyjmowanych przez
* PanelSettingsService::saveAttributes() (ta klasa dokończy normalizację).
* Wiersze bez klucza są pomijane.
*
* @param array<int|string, mixed> $in
* @return list<array<string, mixed>>
*/
protected function sanitizeAttributes(array $in): array
{
$rows = [];
foreach ($in as $row) {
if (! is_array($row) || (string) ($row['key'] ?? '') === '') {
continue;
}
$rows[] = [
'key' => (string) ($row['key'] ?? ''),
'label' => (string) ($row['label'] ?? ''),
'ldap' => (string) ($row['ldap'] ?? ''),
'category' => (string) ($row['category'] ?? 'general'),
'type' => (string) ($row['type'] ?? 'text'),
'regex' => (string) ($row['regex'] ?? ''),
'required' => filter_var($row['required'] ?? false, FILTER_VALIDATE_BOOL),
'help' => (string) ($row['help'] ?? ''),
'column' => filter_var($row['column'] ?? false, FILTER_VALIDATE_BOOL),
];
}
return $rows;
}
}
+23
View File
@@ -0,0 +1,23 @@
# =============================================================================
# Local / no-Traefik override for the development stack (docker/compose.dev.yaml).
#
# cp docker/compose.dev.override.yaml.template docker/compose.dev.override.yaml
# docker compose --env-file .env \
# -f docker/compose.dev.yaml -f docker/compose.dev.override.yaml up --build
#
# The app is published on http://localhost:8080 and the external
# `traefik_public` network becomes a normal local one. Set
# APP_URL=http://localhost:8080 in .env.
# =============================================================================
services:
app:
ports:
- "8080:80"
networks:
- internal
labels: []
networks:
traefik_public:
external: false
+94
View File
@@ -0,0 +1,94 @@
name: adconsole-dev
# =============================================================================
# Development stack — live-reload. The working tree is bind-mounted over
# /var/www/html, so edits to PHP / Blade / assets take effect on the next
# request (opcache revalidates every hit in the `dev` image target). The
# entrypoint runs `composer install` (incl. dev deps) and `key:generate` on
# the first boot of a fresh clone.
#
# Persistent state is kept in named volumes (not docker/data), so `migrate:fresh`
# and `down -v` behave like a throwaway dev box:
#
# app_storage -> /var/www/html/storage
# db_data -> /var/lib/mysql
#
# Run from the repo root:
# cp .env.example .env # APP_ENV=local, APP_DEBUG=true, real LDAP_*
# docker compose --env-file .env -f docker/compose.dev.yaml up --build
#
# Reverse proxy: an external Traefik on `traefik_public`, same as production.
# Without Traefik, uncomment the `ports:` block below and open
# http://localhost:8080 (set APP_URL to match).
# =============================================================================
services:
app:
build:
context: ..
dockerfile: docker/php/Dockerfile
target: dev
container_name: adconsole_dev_app
restart: unless-stopped
environment:
# Reach the DB service by name regardless of DB_HOST in the bind-mounted
# .env (Laravel's dotenv does not override a real env var). Kept minimal so
# `artisan test` inside the container still picks up phpunit.xml's env.
DB_HOST: mariadb
volumes:
- ..:/var/www/html
- app_storage:/var/www/html/storage
# ports:
# - "8080:80"
depends_on:
mariadb:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/up"]
interval: 30s
timeout: 5s
retries: 5
start_period: 120s
networks:
- internal
- traefik_public
labels:
- traefik.enable=true
- traefik.docker.network=traefik_public
- traefik.http.routers.adconsole-dev.rule=Host(`${APP_DOMAIN:-adconsole.localhost}`)
- traefik.http.routers.adconsole-dev.entrypoints=websecure
- traefik.http.routers.adconsole-dev.tls=true
- traefik.http.services.adconsole-dev.loadbalancer.server.port=80
mariadb:
image: mariadb:11
container_name: adconsole_dev_db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-change-me-root}
MARIADB_DATABASE: ${DB_DATABASE:-adconsole}
MARIADB_USER: ${DB_USERNAME:-adconsole}
MARIADB_PASSWORD: ${DB_PASSWORD:-change-me}
MARIADB_AUTO_UPGRADE: "1"
volumes:
- db_data:/var/lib/mysql
# Expose the DB on the host for a GUI client.
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks:
- internal
volumes:
app_storage:
db_data:
networks:
internal:
traefik_public:
external: true
+8 -6
View File
@@ -1,14 +1,16 @@
# =============================================================================
# Local / no-Traefik override.
# Local / no-Traefik override for the production stack (docker/compose.yaml).
#
# Copy to docker/compose.override.yaml (git-ignored) to run the stack without
# a reverse proxy - the app is published straight on a host port and the
# external `traefik_public` network is neutralised.
# Copy it, then pass both files (the override is NOT auto-merged because it
# lives under docker/, not the project root):
#
# cp docker/compose.override.yaml.template docker/compose.override.yaml
# docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
# docker compose --env-file .env \
# -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
#
# Then open http://localhost:8080 (set APP_URL=http://localhost:8080 in .env).
# The app is published on a host port and the external `traefik_public`
# network becomes a normal local one. Open http://localhost:8080
# (set APP_URL=http://localhost:8080 in .env).
# =============================================================================
services:
+46 -16
View File
@@ -1,30 +1,64 @@
name: adconsole
# =============================================================================
# Production-ish Compose stack: PHP/Apache app + MariaDB.
# Production stack — a self-contained PHP/Apache image + MariaDB, behind an
# external Traefik proxy on the `traefik_public` network.
#
# Reverse proxy: this file assumes an external Traefik instance on the
# `traefik_public` network. If you don't use Traefik, drop in a local override
# that publishes a port instead - see docker/compose.override.yaml.template
# and docs/deployment.md.
# By default this runs the image the Gitea release workflow publishes
# (built from docker/php/Dockerfile, target `prod` — code + `composer install
# --no-dev` baked in). `up -d` pulls it; `up -d --build` rebuilds from the
# working tree instead. Nothing from the tree is mounted except `.env`.
# Persistent state lives next to this file:
#
# Run from the repo root:
# docker compose -f docker/compose.yaml up -d --build
# docker/data/app_storage -> /var/www/html/storage (logs, framework cache)
# docker/data/db -> /var/lib/mysql (database — BACK UP)
#
# Run FROM THE REPO ROOT and point Compose at the repo-root .env for
# ${...} interpolation:
#
# cp .env.example .env # then fill it in
# docker compose --env-file .env -f docker/compose.yaml up -d
# docker compose --env-file .env -f docker/compose.yaml logs -f app
#
# No Traefik? Add the local port-publishing override:
# cp docker/compose.override.yaml.template docker/compose.override.yaml
# docker compose --env-file .env \
# -f docker/compose.yaml -f docker/compose.override.yaml up -d
# # -> http://localhost:8080 (set APP_URL to match)
# =============================================================================
services:
app:
# Default: the image the Gitea release workflow (.github/workflows/release.yml)
# publishes for the current release. `docker compose ... pull` fetches it;
# `... up -d --build` rebuilds it locally under the same name instead. Pin a
# different build with ADCONSOLE_VERSION, or override the whole ref with
# ADCONSOLE_IMAGE.
image: ${ADCONSOLE_IMAGE:-gitea.kzbikowski.pl/kzbkowski/adconsole:${ADCONSOLE_VERSION:-1.1.0}}
build:
context: ..
dockerfile: docker/php/Dockerfile
target: prod
container_name: adconsole_app
restart: unless-stopped
environment:
# Reach the DB service by name — overrides the dev-oriented DB_HOST in the
# bind-mounted .env (Laravel's dotenv does not override a real env var).
DB_HOST: mariadb
volumes:
- ..:/var/www/html
- app_storage:/var/www/html/storage
# `.env` is git-ignored and holds the service-account password; it is
# provided at runtime, not baked into the image. Laravel reads it directly.
- ../.env:/var/www/html/.env
- ./data/app_storage:/var/www/html/storage
depends_on:
mariadb:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/up"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
networks:
- internal
- traefik_public
@@ -49,7 +83,7 @@ services:
MARIADB_PASSWORD: ${DB_PASSWORD:-change-me}
MARIADB_AUTO_UPGRADE: "1"
volumes:
- db_data:/var/lib/mysql
- ./data/db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
@@ -59,13 +93,9 @@ services:
networks:
- internal
volumes:
db_data:
app_storage:
networks:
internal:
# Provided by your Traefik deployment. Remove this (and the `traefik_public`
# entry + labels under `app`) if you use the local port-publishing override.
# Provided by your Traefik deployment. If you use the port-publishing
# override instead, it redefines this as a local network.
traefik_public:
external: true
+79 -14
View File
@@ -1,7 +1,25 @@
FROM php:8.4-apache
# syntax=docker/dockerfile:1
# =============================================================================
# ADconsole container image — PHP 8.4 + Apache. Multi-stage:
#
# base : PHP + extensions + Apache (docroot = public/) + Composer + entrypoint
# dev : base only. The repo is bind-mounted at runtime (compose.dev.yaml);
# the entrypoint installs deps + generates APP_KEY on first boot and
# opcache re-checks files on every request, so edits are live.
# prod : base + the application COPYed in and `composer install --no-dev`
# run at build time → a self-contained image (docker/compose.yaml, or
# `docker build`) that you can tag and push straight from the repo;
# only .env and docker/data/ are mounted at runtime.
#
# Build context is the repo root. Default target is `prod` (last stage):
# docker build -f docker/php/Dockerfile -t adconsole:local .
# =============================================================================
FROM php:8.4-apache AS base
# --- System packages + PHP extensions -------------------------------------
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
libcurl4-openssl-dev \
libonig-dev \
libzip-dev \
@@ -11,21 +29,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& docker-php-ext-install -j"$(nproc)" curl mbstring zip pdo pdo_mysql ldap opcache \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Sensible opcache defaults for production.
RUN { \
echo 'opcache.enable=1'; \
echo 'opcache.enable_cli=0'; \
echo 'opcache.memory_consumption=128'; \
echo 'opcache.max_accelerated_files=20000'; \
echo 'opcache.validate_timestamps=1'; \
echo 'opcache.revalidate_freq=60'; \
} > /usr/local/etc/php/conf.d/opcache.ini
# Apache: enable rewrite + serve from public/.
# Apache: enable rewrite + serve from public/ + honour Laravel's public/.htaccess.
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN a2enmod rewrite \
RUN a2enmod rewrite headers \
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \
&& printf '<Directory ${APACHE_DOCUMENT_ROOT}>\n\tAllowOverride All\n\tRequire all granted\n</Directory>\n' \
> /etc/apache2/conf-available/adconsole.conf \
&& a2enconf adconsole
# Composer (from the official image, no network installer).
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
@@ -33,7 +44,61 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/php/entrypoint.sh /usr/local/bin/adconsole-entrypoint
RUN chmod +x /usr/local/bin/adconsole-entrypoint
ENV COMPOSER_ALLOW_SUPERUSER=1
WORKDIR /var/www/html
ENTRYPOINT ["adconsole-entrypoint"]
CMD ["apache2-foreground"]
# =============================================================================
# dev — no app code baked in; compose.dev.yaml bind-mounts the repo over
# /var/www/html. opcache revalidates on every hit so changes show immediately.
# =============================================================================
FROM base AS dev
ENV ADCONSOLE_ENV=dev
RUN { \
echo 'opcache.enable=1'; \
echo 'opcache.enable_cli=0'; \
echo 'opcache.validate_timestamps=1'; \
echo 'opcache.revalidate_freq=0'; \
} > /usr/local/etc/php/conf.d/opcache.ini
# =============================================================================
# prod (default) — self-contained image. The build context is trimmed by
# .dockerignore (no vendor/, node_modules/, tests/, docs/, .env, ./data …).
# =============================================================================
FROM base AS prod
ENV ADCONSOLE_ENV=prod
# Aggressive opcache: the code in the image never changes at runtime.
RUN { \
echo 'opcache.enable=1'; \
echo 'opcache.enable_cli=0'; \
echo 'opcache.memory_consumption=128'; \
echo 'opcache.max_accelerated_files=20000'; \
echo 'opcache.validate_timestamps=0'; \
} > /usr/local/etc/php/conf.d/opcache.ini
# 1. Dependencies — manifests first so this layer survives code-only edits.
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --prefer-dist --no-scripts --no-autoloader
# 2. Application code.
COPY . .
# 3. Optimised autoloader + package discovery, then the writable runtime tree.
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader \
&& mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
storage/app/public \
storage/app/private \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache
+29 -10
View File
@@ -1,21 +1,30 @@
#!/bin/sh
# First-run bootstrap for the ADconsole container. The project is bind-mounted,
# so a fresh `git clone` has no vendor/ and no APP_KEY yet - handle both here so
# `docker compose up` just works. Safe to run on every start (all steps are
# idempotent).
# First-run / every-boot bootstrap for the ADconsole container. Every step is
# idempotent, so it is safe to run on each start.
#
# dev (ADCONSOLE_ENV=dev): the repo is bind-mounted. Install deps (incl.
# dev), generate APP_KEY, clear caches only — no
# config cache, so .env edits take effect on reload.
# prod (default): code + vendor are baked into the image. Just fix
# storage ownership, migrate and warm the caches.
set -e
cd /var/www/html
if [ ! -f .env ]; then
echo "[adconsole] .env is missing - copy .env.example to .env and fill it in." >&2
echo "[adconsole] .env is missing — mount your filled-in .env at /var/www/html/.env" >&2
echo " (cp .env.example .env, edit it, then start the stack again)" >&2
exit 1
fi
# 1. PHP dependencies.
# 1. PHP dependencies — only when absent (fresh clone in dev; never in prod).
if [ ! -f vendor/autoload.php ]; then
echo "[adconsole] installing composer dependencies..."
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
if [ "$ADCONSOLE_ENV" = "dev" ]; then
composer install --no-interaction --prefer-dist
else
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
fi
fi
# 2. Application key.
@@ -24,15 +33,25 @@ if ! grep -qE '^APP_KEY=.+' .env; then
php artisan key:generate --force
fi
# 3. Writable paths (bind-mounted repo is owned by the host user).
# 3. Writable storage tree — the bind mount / volume can start empty.
mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
storage/app/public \
storage/app/private \
bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
# 4. Database schema + storage symlink.
php artisan migrate --force
php artisan storage:link 2>/dev/null || true
# 5. Framework caches (config/route/view/event). Refreshed on every boot.
# 5. Framework caches.
php artisan optimize:clear
php artisan optimize
if [ "$ADCONSOLE_ENV" != "dev" ]; then
php artisan optimize
fi
exec "$@"
+40 -2
View File
@@ -8,12 +8,49 @@ incompatible changes to configuration, the database schema or AD-permission
requirements; **MINOR** for backwards-compatible features; **PATCH** for
backwards-compatible fixes. Each release is a git tag `vX.Y.Z`; the latest tag on
`main` is the supported version. Upgrade notes live in
[`docs/upgrading.md`](docs/upgrading.md).
[`upgrading.md`](upgrading.md).
## [Unreleased]
_Nothing yet._
## [1.1.0] - 2026-09-10
### Added
- **Configuration backup.** `/settings` → *General* tab can now export the whole
panel configuration (general settings + attribute definitions) to a JSON file
and import it back. Export can optionally leave out the attribute-encryption
key. Both actions are recorded in the audit log (`config.export`,
`config.import`). No Active Directory data is included.
- **`docker/compose.dev.yaml`** — a live-reload development stack that
bind-mounts the working tree; `docker/php/Dockerfile` is now multi-stage with
`dev` / `prod` targets. Each stack has a no-Traefik override template.
- **Release image.** `.github/workflows/release.yml` builds the `prod` image on
every `vX.Y.Z` tag and pushes it to the Gitea container registry as
`gitea.kzbikowski.pl/kzbkowski/adconsole:{X.Y.Z, X.Y, latest}`.
`docker/compose.yaml` now defaults to that image (`ADCONSOLE_VERSION` pins a
tag, `ADCONSOLE_IMAGE` replaces the reference); `docker compose … up -d`
pulls it, `… --build` still rebuilds locally.
### Changed
- **Docker: the production image is now self-contained.** `docker/compose.yaml`
builds `docker/php/Dockerfile` target `prod`, which `COPY`s the app in and
runs `composer install --no-dev` at build time — the working tree is no longer
bind-mounted (only `.env`). Persistent state moved from Docker named volumes
to **`docker/data/app_storage`** and **`docker/data/db`** bind mounts.
Apache now honours Laravel's `public/.htaccess` (`AllowOverride All`).
Run Compose from the repo root with `--env-file .env`, e.g.
`docker compose --env-file .env -f docker/compose.yaml up -d`.
**Upgrading an existing Docker deployment:** copy the old named volumes into
`docker/data/` before the first `up` with the new file, e.g.
`docker run --rm -v adconsole_db_data:/from -v "$PWD/docker/data/db":/to alpine cp -a /from/. /to/`
(same for `adconsole_app_storage` → `docker/data/app_storage`). See
[upgrading.md](upgrading.md#v110--docker-layout).
## [1.0.0] - 2026-09-08
First public release. Consolidates the prior internal builds (`0.1.0`) into a
@@ -106,5 +143,6 @@ Initial internal release: users, computers, groups, read-only GPO, org chart,
dashboard, panel configuration, PL/EN UI, LDAP-group login. Never tagged or
published; superseded by `1.0.0`.
[Unreleased]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.0.0...HEAD
[Unreleased]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.1.0...HEAD
[1.1.0]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.0.0...v1.1.0
[1.0.0]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/releases/tag/v1.0.0
File renamed without changes.
+5 -5
View File
@@ -6,8 +6,8 @@ very welcome.
## Before you start
- Read [`CLAUDE/AGENTS.md`](CLAUDE/AGENTS.md) (the architecture in one page) and
[`docs/architecture.md`](docs/architecture.md).
- Read [`CLAUDE/AGENTS.md`](../CLAUDE/AGENTS.md) (the architecture in one page) and
[`architecture.md`](architecture.md).
- By participating you agree to the [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
- For anything non-trivial, open an issue first so we can agree on the approach.
@@ -20,7 +20,7 @@ very welcome.
3. **Keep it dependency-light.** No new runtime Composer/npm package without a
clear reason in the issue.
4. **Least privilege.** New AD operations must be documented in
[`docs/active-directory.md`](docs/active-directory.md) with the exact right
[`active-directory.md`](active-directory.md) with the exact right
they require.
5. **Security-sensitive by nature** — think about what a hostile panel user or a
compromised service account could do with your change.
@@ -35,7 +35,7 @@ php artisan test
vendor/bin/pint
```
See [`docs/development.md`](docs/development.md) for the details (SQLite for dev,
See [`development.md`](development.md) for the details (SQLite for dev,
the fake `ldap` guard in tests, the harmless `DEPR` tag, etc.).
## Pull requests
@@ -64,4 +64,4 @@ and steps to reproduce.
## Licence
By contributing you agree your work is released under the project's
[MIT Licence](LICENSE).
[MIT Licence](../LICENSE).
+5 -1
View File
@@ -11,7 +11,11 @@
| [troubleshooting.md](troubleshooting.md) | Fix "the list is empty", "password reset fails", etc. |
| [upgrading.md](upgrading.md) | Pull a new version safely. |
| [publishing.md](publishing.md) | Turn this tree into a clean shared/public repo. |
| [CONTRIBUTING.md](CONTRIBUTING.md) | Set up a dev loop and get a change merged. |
| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Know the behaviour expected of contributors. |
| [CHANGELOG.md](CHANGELOG.md) | See what changed between releases. |
| [SECURITY.md](SECURITY.md) | Report a vulnerability; understand the security scope. |
Start with the [project README](../README.md) for the 5-minute overview.
Security policy and vulnerability reporting: [../SECURITY.md](../SECURITY.md).
Security policy and vulnerability reporting: [SECURITY.md](SECURITY.md).
+2 -2
View File
@@ -40,12 +40,12 @@ ADconsole is a privileged tool. When you deploy it, **you** are responsible for:
- An encrypted LDAP channel (`LDAP_SSL` / `LDAP_TLS`) and, ideally,
`LDAP_TLS_VERIFY=true`.
- Delegating the service account **only** the AD rights you actually use
([docs/active-directory.md](docs/active-directory.md)).
([active-directory.md](active-directory.md)).
- Keeping `AD_LOGIN_GROUP` small and audited.
- Protecting `.env` (file mode `600`) — it contains the service-account password.
- Applying updates promptly.
See the [hardening checklist](docs/deployment.md#hardening-checklist).
See the [hardening checklist](deployment.md#hardening-checklist).
## Supported versions
+17 -1
View File
@@ -35,7 +35,7 @@ panel's own config (`panel_settings`, `panel_attributes`) and the change log
|---|---|
| `DB_CONNECTION` | `mysql` (MariaDB/MySQL), `pgsql`, or `sqlite`. |
| `DB_HOST` / `DB_PORT` / `DB_DATABASE` / `DB_USERNAME` / `DB_PASSWORD` | Connection. |
| `DB_ROOT_PASSWORD` | Only consumed by `docker/compose.yaml` on first volume init. |
| `DB_ROOT_PASSWORD` | Only consumed by `docker/compose.yaml` when it first initialises `docker/data/db`. |
### Sessions / cache / queue
@@ -122,3 +122,19 @@ Locked rows that can't be removed or retyped: `login` (sAMAccountName),
`distinguishedName`, the computed `status` and `password_expiry`, and
`account_expiry` (fixed `date`, `accountExpires`). If deleted directly in SQL
they are re-created from the defaults on the next read.
### Backup / restore
The *General* tab has a **Configuration backup** card:
- **Export** downloads a JSON file with the General-tab settings and the full
attribute list (`App\Services\ConfigBackupService::export()`). Tick *leave the
encryption key out* to blank `encryption.key` in the file.
- **Import** reads such a file back and overwrites `panel_settings` (key `panel`)
and `panel_attributes`. Values are re-validated on the way in (ranges clamped,
locked attribute rows re-asserted), so a hand-edited file can't push the panel
into a bad state. A file that isn't a `format: adconsole-config` JSON, or that
declares a newer schema `version`, is rejected with a form error.
Both actions write a `panel_audit` row (`config.export` / `config.import`). The
backup never contains Active Directory data or the audit log itself.
+90 -29
View File
@@ -1,17 +1,39 @@
# Deployment (Docker)
The repo ships a small production stack in `docker/`:
Everything Docker lives in `docker/`:
- **`docker/compose.yaml`** — production. A **self-contained image**: the app
code and `composer install --no-dev` are baked in at build time
(`docker/php/Dockerfile`, target `prod`). Nothing from the working tree is
mounted except `.env`. Persistent state lives in `docker/data/`. Wired for an
**external Traefik** proxy on the `traefik_public` network.
- **`docker/compose.dev.yaml`** — development / live-reload. Bind-mounts the
working tree over `/var/www/html`; edits to PHP / Blade / assets apply on the
next request. State is in throwaway named volumes. See
[development.md](development.md).
- `docker/compose.override.yaml.template` /
`docker/compose.dev.override.yaml.template` — copy each to the matching
`*.override.yaml` (git-ignored) to run **without** Traefik by publishing a
host port.
- `docker/php/Dockerfile` — PHP 8.4 + Apache, `ldap`/`pdo_mysql`/`opcache`,
Composer, and an entrypoint that self-bootstraps on first boot.
- `docker/php/entrypoint.sh` — installs `vendor/` if missing, generates
`APP_KEY`, fixes `storage/` ownership, runs `migrate --force`, warms caches,
then starts Apache. Idempotent — runs on every start.
- `docker/compose.yaml` — `app` + `mariadb`, wired for an **external Traefik**
proxy on the `traefik_public` network.
- `docker/compose.override.yaml.template` — copy to
`docker/compose.override.yaml` (git-ignored) to run **without** Traefik by
publishing a host port.
Composer. Stages: `base` → `dev` (no code baked in) / `prod` (code + deps
baked in).
- `docker/php/entrypoint.sh` — on every boot: install `vendor/` if missing,
generate `APP_KEY`, create + `chown` the `storage/` tree, `migrate --force`,
warm caches (prod) / just clear them (dev). Idempotent.
## Invocation
Because the Compose files sit in `docker/`, always run **from the repo root**
and pass `--env-file .env` so `${DB_*}` / `${APP_DOMAIN}` interpolation reads
the repo-root `.env`:
```sh
docker compose --env-file .env -f docker/compose.yaml <command>
```
(Tip: `export COMPOSE_FILE=docker/compose.yaml COMPOSE_ENV_FILES=.env` once per
shell, then plain `docker compose …` works.)
## With Traefik
@@ -23,51 +45,89 @@ cp .env.example .env
# DB_PASSWORD / DB_ROOT_PASSWORD <- set real values
# LDAP_* / AD_* <- your domain
docker compose -f docker/compose.yaml up -d --build
docker compose -f docker/compose.yaml logs -f app # watch the bootstrap
docker compose --env-file .env -f docker/compose.yaml up -d # pulls the release image
docker compose --env-file .env -f docker/compose.yaml logs -f app # watch bootstrap
```
`docker/compose.yaml` defaults to the image the Gitea release workflow publishes
(`gitea.kzbikowski.pl/kzbkowski/adconsole:1.1.0`), so `up -d` **pulls** it — no
local build. Add `--build` to build from your working tree instead; the result
is tagged with the same name.
Enable a certificate resolver by uncommenting the `certresolver` label in
`docker/compose.yaml` and setting its name to match your Traefik config.
The `.env` file is **bind-mounted**, not baked in: edit it and
`docker compose restart app` — no rebuild needed. (This is deliberate; see the
comment in `compose.yaml`.)
The `.env` file is **bind-mounted**, not baked into the image: edit it and
`docker compose … restart app` — no rebuild needed. Everything else is
immutable.
### The image
`.github/workflows/release.yml` builds `docker/php/Dockerfile` target `prod` on
every `vX.Y.Z` tag and pushes `…/adconsole:{X.Y.Z, X.Y, latest}` to the Gitea
container registry (needs Actions enabled and `packages: write`; set a
`REGISTRY_TOKEN` secret if the built-in token is not allowed to push).
Deploy a specific release:
```sh
ADCONSOLE_VERSION=1.1.0 \
docker compose --env-file .env -f docker/compose.yaml pull
ADCONSOLE_VERSION=1.1.0 \
docker compose --env-file .env -f docker/compose.yaml up -d
```
Set `ADCONSOLE_VERSION` (or the full `ADCONSOLE_IMAGE`) in `.env` to make it
stick. A different registry / private mirror: `ADCONSOLE_IMAGE=host/adconsole:tag`.
To publish from a workstation instead of CI:
`docker compose … build && docker compose … push`.
## Without Traefik
```sh
cp docker/compose.override.yaml.template docker/compose.override.yaml
# APP_URL=http://localhost:8080 in .env
docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
docker compose --env-file .env \
-f docker/compose.yaml -f docker/compose.override.yaml up -d --build
```
App on `http://localhost:8080`, MariaDB on `localhost:3306`. Put a TLS-terminating
proxy (nginx, Caddy, your load balancer) in front for anything but a lab.
App on `http://localhost:8080`, MariaDB on `localhost:3306`. Put a
TLS-terminating proxy (nginx, Caddy, your load balancer) in front for anything
but a lab.
## Data & backups
| What | Where | Back up? |
|---|---|---|
| Database (`panel_settings` + `panel_attributes` config, `panel_audit` change log, sessions, cache) | `db_data` volume | **Yes** — `panel_settings` / `panel_attributes` are your config and `panel_audit` is your only record of who changed what. `mysqldump adconsole`. |
| `storage/` (logs, framework cache, sessions if file-driver) | `app_storage` volume | Optional (logs only really). |
| `.env` | bind-mounted from the repo | **Yes**, securely — it holds the service-account password. |
Production state is under **`docker/data/`** (git-ignored), created on first run:
Restore config elsewhere: `mysqldump`/import the `panel_settings` and
`panel_attributes` tables, or re-enter it in `/settings`.
| What | Path | Back up? |
|---|---|---|
| Database (`panel_settings` + `panel_attributes` config, `panel_audit` change log, sessions, cache) | `docker/data/db/` | **Yes** — `panel_settings` / `panel_attributes` are your config and `panel_audit` is your only record of who changed what. `docker compose … exec mariadb mariadb-dump -uroot -p adconsole`. |
| `storage/` (logs, framework cache, sessions if file-driver) | `docker/data/app_storage/` | Optional (logs only really). |
| `.env` | bind-mounted from the repo root | **Yes**, securely — it holds the service-account password. |
A file-level copy of `docker/data/db/` is only consistent with the container
stopped; prefer `mariadb-dump` on a running stack. The panel's own config also
has a JSON export at `/settings` → *General* → *Configuration backup*.
> On **rootless Docker** the MariaDB image can fail to `chown` a bind-mounted
> datadir. Either run the daemon rootful, or switch `docker/compose.yaml`'s
> `mariadb` volume to a named volume (as `docker/compose.dev.yaml` already does).
**Audit-log retention.** `panel_audit` grows with panel activity and nothing
prunes it automatically. If you need a retention window, trim it with a scheduled
job of your own, e.g.
prunes it automatically. If you need a retention window, trim it with a
scheduled job of your own, e.g.
`DELETE FROM panel_audit WHERE created_at < NOW() - INTERVAL 365 DAY;`, and
back it up (or ship it to a SIEM) before trimming if the entries have
compliance value.
## Upgrades
Bump `ADCONSOLE_VERSION` in `.env` (or `git pull` if you track `main` and rely
on the `latest` tag), then:
```sh
git pull
docker compose -f docker/compose.yaml up -d --build
docker compose --env-file .env -f docker/compose.yaml pull
docker compose --env-file .env -f docker/compose.yaml up -d
```
The entrypoint runs `migrate --force` and `optimize` on the new container. See
@@ -84,6 +144,7 @@ The entrypoint runs `migrate --force` and `optimize` on the new container. See
- [ ] Panel reachable only from admin networks / VPN (proxy ACL or firewall)
- [ ] DB not exposed to the internet; strong `DB_PASSWORD` / `DB_ROOT_PASSWORD`
- [ ] `.env` file permissions `600`, owned by the deploy user
- [ ] `docker/data/` on a backed-up filesystem, permissions restricted to the deploy user
- [ ] Log shipping / retention configured; review `LDAP_LOGGING` volume
- [ ] Backups of the database tested
+33
View File
@@ -27,6 +27,39 @@ You still need a reachable domain controller for the AD-backed screens. Without
one, the auth and list pages will error — the test suite does **not** need a DC
(it fakes the guard and mocks LdapRecord).
## Local setup — Docker (live reload)
```sh
git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adconsole
cp .env.example .env # APP_ENV=local, APP_DEBUG=true, real LDAP_* / AD_*
docker compose --env-file .env -f docker/compose.dev.yaml up --build
```
Run from the repo root; `--env-file .env` lets Compose interpolate `${DB_*}`
from the repo-root `.env` (the file lives in `docker/`). Set
`export COMPOSE_FILE=docker/compose.dev.yaml COMPOSE_ENV_FILES=.env` once to
drop the flags.
The working tree is bind-mounted over `/var/www/html`, so PHP / Blade / CSS / JS
edits apply on the next request (the `dev` image target sets
`opcache.revalidate_freq=0`). On the first boot of a fresh clone the entrypoint
runs `composer install` (with dev deps) and `php artisan key:generate`, then
`migrate`. `vendor/` lands back on the host, so your IDE resolves it too.
Database and `storage/` are throwaway named volumes — add `-v` to
`docker compose … down` to reset the box. MariaDB is published on
`localhost:3306`. Run artisan / tools inside the container:
```sh
docker compose --env-file .env -f docker/compose.dev.yaml exec app php artisan migrate:fresh
docker compose --env-file .env -f docker/compose.dev.yaml exec app php artisan test
docker compose --env-file .env -f docker/compose.dev.yaml exec app vendor/bin/pint
```
`compose.dev.yaml` expects an external Traefik on `traefik_public`; without one,
`cp docker/compose.dev.override.yaml.template docker/compose.dev.override.yaml`
and add `-f docker/compose.dev.override.yaml` (publishes `http://localhost:8080`).
## Tests
```sh
+15 -7
View File
@@ -13,7 +13,7 @@ If you re-publish under a different owner/host, revisit these:
| File | Was | Now / put |
|---|---|---|
| `README.md`, `docs/installation.md`, `docs/development.md` | `<your-fork-url>` in the `git clone` lines | `https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git` |
| `SECURITY.md` | `<SET A SECURITY CONTACT HERE>` | `mail@kzbikowski.pl` |
| `docs/SECURITY.md` | `<SET A SECURITY CONTACT HERE>` | `mail@kzbikowski.pl` |
| `.github/ISSUE_TEMPLATE/config.yml` | `https://github.com/OWNER/REPO/...` | The repo's Gitea issue/wiki URLs |
| `LICENSE` | `2026 Kacper Żbikowski` | Copyright year + holder, if different |
| `lang/{pl,en}/pages.php` → `author.*` | `Kacper Żbikowski` | Author/maintainer shown on the in-app *About* page |
@@ -33,7 +33,8 @@ none of it slipped in:
- `vendor/`, `node_modules/`, `public/build/`, `public/hot/`
- `storage/` runtime (logs, sessions, framework cache) — only the `.gitignore`
keep-files should be tracked
- `docker/compose.override.yaml`
- `docker/compose.override.yaml`, `docker/compose.dev.override.yaml`
- `docker/data/` (production DB + storage state)
- IDE / agent dirs (`.idea/`, `.vscode/`, `.claude/`, …)
```sh
@@ -82,11 +83,18 @@ git push origin v1.0.0
`laravel`, `ldaprecord`, `admin-panel`, `self-hosted`.
- Enable **Issues**. Point the "Question / help" contact link at the repo's
issues (Gitea has no Discussions).
- The GitHub-format templates in `.github/` (issue/PR templates, the
Pint + PHPUnit CI workflow on PHP 8.2/8.3/8.4) are read by Gitea Actions too if
Actions is enabled on the instance; otherwise they are harmless.
- Cut a **release** from the `v1.0.0` tag and paste the matching `CHANGELOG.md`
section as the notes.
- The workflows in `.github/workflows/` are read by Gitea Actions if Actions is
enabled on the instance (otherwise harmless):
- `ci.yml` — Pint + PHPUnit on PHP 8.2/8.3/8.4, on every push / PR.
- `release.yml` — on a `vX.Y.Z` tag, builds `docker/php/Dockerfile` target
`prod` and pushes `…/adconsole:{X.Y.Z, X.Y, latest}` to the instance's
container registry. Needs `packages: write`; if the built-in token can't
push, add a `REGISTRY_TOKEN` repo secret. Adjust `REGISTRY` / `IMAGE_NAME`
in the workflow (and the default in `docker/compose.yaml`) for a different
host or owner.
- Release: `git tag -a vX.Y.Z -m "ADconsole vX.Y.Z" && git push origin vX.Y.Z`
(that fires `release.yml`), then cut a **release** from the tag on the Git host
and paste the matching `docs/CHANGELOG.md` section as the notes.
## 6. First-deploy sanity (for whoever installs it)
+40 -4
View File
@@ -14,7 +14,7 @@ From `v1.0.0` on, releases are [semantic](https://semver.org/) git tags
The latest tag on `main` is the supported version; there are no long-term-support
branches. Per-version notes are under [Version notes](#version-notes) below and
in [`CHANGELOG.md`](../CHANGELOG.md).
in [`CHANGELOG.md`](CHANGELOG.md).
## General procedure
@@ -29,13 +29,49 @@ php artisan optimize:clear
php artisan optimize # config + route + view + event cache
```
Docker: `docker compose -f docker/compose.yaml up -d --build` — the entrypoint
runs `migrate --force` and `optimize` for you.
Docker (from the repo root): bump `ADCONSOLE_VERSION` in `.env`, then
`docker compose --env-file .env -f docker/compose.yaml pull` and
`… up -d`. The entrypoint runs `migrate --force` and `optimize` for you.
Roll back = restore the DB dump and check out the previous revision.
Roll back = restore the DB dump and either check out the previous revision or set
`ADCONSOLE_VERSION` back to the previous tag.
## Version notes
### v1.1.0 — Docker layout
The Docker files moved and the production image changed shape. One-time steps
for an **existing** Docker install:
1. `git pull` — `docker/compose.yaml` now lives in `docker/`, builds a
self-contained image, and reads persistent state from **`docker/data/`**
bind mounts instead of the `adconsole_db_data` / `adconsole_app_storage`
named volumes.
2. Stop the old stack: `docker compose -f docker/compose.yaml down` (old file).
3. Copy the volume contents into `docker/data/` **before** the first new `up`:
```sh
mkdir -p docker/data/db docker/data/app_storage
docker run --rm -v adconsole_db_data:/from -v "$PWD/docker/data/db":/to \
alpine cp -a /from/. /to/
docker run --rm -v adconsole_app_storage:/from -v "$PWD/docker/data/app_storage":/to \
alpine cp -a /from/. /to/
```
4. Start it, always from the repo root and with `--env-file .env`:
```sh
docker compose --env-file .env -f docker/compose.yaml up -d
```
(`export COMPOSE_FILE=docker/compose.yaml COMPOSE_ENV_FILES=.env` once to drop
the flags.) `up -d` now **pulls** the released image; add `--build` to build
from the working tree.
Rootless Docker can't `chown` a bind-mounted MariaDB datadir — run the daemon
rootful, or switch the `mariadb` volume in `docker/compose.yaml` to a named
volume. Nothing changes for a **bare-metal** install.
### Panel configuration moved into the database
Two migrations, both automatic — just run `php artisan migrate` (the Docker
+2
View File
@@ -37,6 +37,8 @@ return [
'actions' => [
'config.settings' => 'Panel settings',
'config.attributes' => 'Attribute configuration',
'config.export' => 'Configuration export',
'config.import' => 'Configuration import',
'user.create' => 'User created',
'user.update' => 'User edited',
+17
View File
@@ -74,6 +74,23 @@ return [
'tpl_ldap_placeholder' => 'e.g. extensionAttribute5',
],
'backup' => [
'heading' => 'Configuration backup',
'intro' => 'Download the whole panel configuration (the settings from the <strong>General</strong> tab and the attribute definitions) as a JSON file, or restore it from a file you saved earlier. The backup does not include Active Directory data or the audit log.',
'export_heading' => 'Export (download file)',
'export_btn' => 'Download backup',
'exclude_key' => 'Leave the attribute encryption key out of the file',
'import_heading' => 'Import (load file)',
'import_btn' => 'Load and overwrite',
'import_confirm' => 'Loading a file overwrites the current panel configuration and the attribute list. Continue?',
'warning' => 'The backup file holds the full panel configuration — <strong>including the attribute encryption key</strong> unless you tick the option to leave it out. Keep it somewhere safe.',
'flash_imported' => 'Configuration loaded from the backup file.',
'error_not_json' => 'The file could not be read — it is not valid JSON.',
'error_bad_format' => 'This file is not an ADconsole configuration backup.',
'error_bad_version' => 'The backup file comes from a newer version of the panel and cannot be loaded.',
'error_empty' => 'The backup file contains no settings or attributes.',
],
'flash' => [
'saved' => 'Panel configuration has been saved.',
'attributes_saved' => 'Attribute configuration has been saved.',
+2
View File
@@ -37,6 +37,8 @@ return [
'actions' => [
'config.settings' => 'Ustawienia panelu',
'config.attributes' => 'Konfiguracja atrybutów',
'config.export' => 'Eksport konfiguracji',
'config.import' => 'Import konfiguracji',
'user.create' => 'Utworzenie użytkownika',
'user.update' => 'Edycja użytkownika',
+17
View File
@@ -74,6 +74,23 @@ return [
'tpl_ldap_placeholder' => 'np. extensionAttribute5',
],
'backup' => [
'heading' => 'Kopia zapasowa konfiguracji',
'intro' => 'Pobierz całą konfigurację panelu (ustawienia z zakładki <strong>Ogólne</strong> oraz definicje atrybutów) do pliku JSON albo wczytaj ją z wcześniej pobranego pliku. Kopia nie obejmuje danych z Active Directory ani dziennika zmian.',
'export_heading' => 'Eksport (pobierz plik)',
'export_btn' => 'Pobierz kopię',
'exclude_key' => 'Pomiń w pliku klucz szyfrujący atrybuty',
'import_heading' => 'Import (wczytaj plik)',
'import_btn' => 'Wczytaj i nadpisz',
'import_confirm' => 'Wczytanie pliku nadpisze bieżącą konfigurację panelu oraz listę atrybutów. Kontynuować?',
'warning' => 'Plik kopii zawiera pełną konfigurację panelu — <strong>w tym klucz szyfrujący atrybuty</strong>, o ile nie zaznaczysz opcji jego pominięcia. Przechowuj go w bezpiecznym miejscu.',
'flash_imported' => 'Konfigurację wczytano z pliku kopii zapasowej.',
'error_not_json' => 'Nie udało się odczytać pliku — to nie jest poprawny JSON.',
'error_bad_format' => 'Ten plik nie jest kopią zapasową konfiguracji ADconsole.',
'error_bad_version' => 'Plik kopii pochodzi z nowszej wersji panelu i nie może zostać wczytany.',
'error_empty' => 'Plik kopii nie zawiera żadnych ustawień ani atrybutów.',
],
'flash' => [
'saved' => 'Konfiguracja panelu została zapisana.',
'attributes_saved' => 'Konfiguracja atrybutów została zapisana.',
@@ -133,6 +133,55 @@
</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-8">
<div class="card card-custom">
<div class="card-body">
<h6 class="fw-bold text-uppercase text-muted mb-3" style="font-size: 0.75rem; letter-spacing: 0.05em;">{{ __('config.backup.heading') }}</h6>
<p class="form-text mb-3" style="max-width: 640px;">{!! __('config.backup.intro') !!}</p>
<div class="row g-4">
{{-- Eksport --}}
<div class="col-md-6">
<form method="GET" action="{{ route('config.export') }}">
<div class="fw-semibold small mb-2">{{ __('config.backup.export_heading') }}</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="exclude_key" value="1" id="cfgBackupExcludeKey">
<label class="form-check-label small" for="cfgBackupExcludeKey">{{ __('config.backup.exclude_key') }}</label>
</div>
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-download me-1"></i> {{ __('config.backup.export_btn') }}
</button>
</form>
</div>
{{-- Import --}}
<div class="col-md-6">
<form method="POST" action="{{ route('config.import') }}" enctype="multipart/form-data"
onsubmit="return confirm('{{ __('config.backup.import_confirm') }}');">
@csrf
<div class="fw-semibold small mb-2">{{ __('config.backup.import_heading') }}</div>
<input type="file" class="form-control form-control-sm mb-2 @error('config_file') is-invalid @enderror"
name="config_file" accept=".json,application/json" required>
@error('config_file')
<div class="invalid-feedback d-block mb-2">{{ $message }}</div>
@enderror
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-upload me-1"></i> {{ __('config.backup.import_btn') }}
</button>
</form>
</div>
</div>
<div class="alert alert-warning small mt-3 mb-0">
<i class="bi bi-shield-exclamation me-1"></i>
{!! __('config.backup.warning') !!}
</div>
</div>
</div>
</div>
</div>
</div>
{{-- ================= ZAKŁADKA: ATRYBUTY ================= --}}
+4
View File
@@ -140,4 +140,8 @@ Route::prefix('settings')->name('config.')->middleware('auth:ldap')->group(funct
Route::get('/', [ConfigController::class, 'index'])->name('index');
Route::put('/', [ConfigController::class, 'update'])->name('update');
Route::put('/attributes', [ConfigController::class, 'updateAttributes'])->name('attributes.update');
// Kopia zapasowa konfiguracji panelu (bez danych z AD).
Route::get('/export', [ConfigController::class, 'export'])->name('export');
Route::post('/import', [ConfigController::class, 'import'])->name('import');
});
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace Tests\Feature;
use App\Services\ConfigBackupService;
use App\Services\PanelSettingsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Tests\TestCase;
class ConfigBackupTest extends TestCase
{
// Konfiguracja panelu jest w bazie - każdy test startuje na czystej,
// czyli na wbudowanych domyślnych.
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->actingAsPanelAdmin();
}
/** @param array<string, mixed> $overrides */
protected function backupFile(array $overrides = []): UploadedFile
{
$payload = array_replace(
app(ConfigBackupService::class)->export(),
$overrides,
);
return UploadedFile::fake()->createWithContent(
'adconsole-config.json',
json_encode($payload, JSON_UNESCAPED_UNICODE),
);
}
public function test_export_downloads_a_json_backup_with_the_expected_shape(): void
{
$res = $this->get('/settings/export')->assertOk();
$this->assertStringContainsString('attachment', $res->headers->get('content-disposition'));
$this->assertStringContainsString('adconsole-config-', $res->headers->get('content-disposition'));
$res->assertJsonPath('format', 'adconsole-config')
->assertJsonPath('version', 1)
->assertJsonStructure(['format', 'version', 'exported_at', 'settings', 'attributes']);
$data = $res->json();
$this->assertSame(42, $data['settings']['password_policy']['max_age_days']);
// Pełna lista domyślna: 24 atrybuty AD + 2 kolumny wyliczane.
$this->assertCount(26, $data['attributes']);
}
public function test_export_can_leave_out_the_encryption_key(): void
{
$this->put('/settings', [
'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'],
'password_policy' => ['max_age_days' => '42'],
'encryption' => ['key' => 'TopSecret!'],
])->assertRedirect();
$this->get('/settings/export')
->assertJsonPath('settings.encryption.key', 'TopSecret!')
->assertJsonPath('encryption_key_included', true);
$this->get('/settings/export?exclude_key=1')
->assertJsonPath('settings.encryption.key', '')
->assertJsonPath('encryption_key_included', false);
}
public function test_import_restores_general_settings_and_attributes(): void
{
// Zmień konfigurację, zrób kopię, przywróć domyślne, wczytaj kopię.
$this->put('/settings', [
'reset_password' => ['must_change_password' => '0', 'unlock_account' => '0'],
'password_policy' => ['max_age_days' => '17'],
'audit' => ['require_reason' => '1'],
])->assertRedirect();
$this->put('/settings/attributes', [
'attributes' => [
['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => '1'],
['key' => 'badgeId', 'label' => 'Identyfikator', 'ldap' => 'extensionAttribute9', 'category' => 'hr', 'type' => 'text', 'column' => '1'],
],
])->assertRedirect();
$file = $this->backupFile();
// Wróć do domyślnych.
app(PanelSettingsService::class)->save(app(PanelSettingsService::class)->defaults());
app(PanelSettingsService::class)->saveAttributes([]);
$this->assertSame(42, app(PanelSettingsService::class)->passwordMaxAgeDays());
$this->post('/settings/import', ['config_file' => $file])
->assertRedirect()
->assertSessionHas('status')
->assertSessionHasNoErrors();
$fresh = app(PanelSettingsService::class);
$this->assertSame(17, $fresh->passwordMaxAgeDays());
$this->assertTrue($fresh->requireReason());
$byKey = collect($fresh->attributes())->keyBy('key');
$this->assertTrue($byKey->has('badgeId'));
$this->assertSame('extensionAttribute9', $byKey['badgeId']['ldap']);
$this->assertTrue($byKey['title']['column']);
// Pozycje systemowe / wyliczane zostają odtworzone przez saveAttributes().
$this->assertTrue($byKey->has('login'));
$this->assertTrue($byKey->has('status'));
}
public function test_import_writes_an_audit_entry(): void
{
$this->post('/settings/import', ['config_file' => $this->backupFile()])
->assertRedirect();
$this->assertDatabaseHas('panel_audit', ['action' => 'config.import']);
}
public function test_import_rejects_a_file_that_is_not_an_adconsole_backup(): void
{
$file = UploadedFile::fake()->createWithContent('nope.json', json_encode(['hello' => 'world']));
$this->post('/settings/import', ['config_file' => $file])
->assertRedirect()
->assertSessionHasErrors('config_file');
$this->assertDatabaseMissing('panel_audit', ['action' => 'config.import']);
}
public function test_import_rejects_a_file_that_is_not_valid_json(): void
{
$file = UploadedFile::fake()->createWithContent('broken.json', '{ not json ');
$this->post('/settings/import', ['config_file' => $file])
->assertRedirect()
->assertSessionHasErrors('config_file');
}
public function test_import_rejects_a_backup_from_a_newer_schema_version(): void
{
$file = $this->backupFile(['version' => 999]);
$this->post('/settings/import', ['config_file' => $file])
->assertRedirect()
->assertSessionHasErrors('config_file');
}
public function test_import_sanitises_out_of_range_values_from_a_hand_edited_file(): void
{
$file = $this->backupFile([
'settings' => [
'password_policy' => ['max_age_days' => 999999, 'generator_length' => 2],
'audit' => ['require_reason' => 'yes'],
],
]);
$this->post('/settings/import', ['config_file' => $file])->assertRedirect()->assertSessionHasNoErrors();
$fresh = app(PanelSettingsService::class);
$this->assertSame(3650, $fresh->passwordMaxAgeDays());
$this->assertSame(8, $fresh->passwordGenerator()['length']);
$this->assertTrue($fresh->requireReason());
}
public function test_settings_page_shows_the_backup_card(): void
{
$this->get('/settings')
->assertOk()
->assertSee(__('config.backup.heading'))
->assertSee(route('config.export'), escape: false);
}
}