Compare commits
42 commits
v0.1.0.003
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9364b4af81 | |||
| 6dedc6d432 | |||
| 4a9d5f7ede | |||
| 517c8849ec | |||
| e1a4566cc3 | |||
| f4fe31c561 | |||
| 5cff118d0c | |||
| 44326c542d | |||
| 48cda41d69 | |||
| 6cf084b931 | |||
| 359a0dccb6 | |||
| ae9b0b7a81 | |||
| aa1be0d4cd | |||
| b32180c268 | |||
| d82b1a9a6b | |||
| 03ae910b3f | |||
| 1849c65c68 | |||
| a21e63c4a2 | |||
| 0d7defff4e | |||
| 14e60151fd | |||
| 62b42635b2 | |||
| 9f6c85ce60 | |||
| 7303c94a3c | |||
| f0c6aa1a2e | |||
| 080341f8a8 | |||
| 034e6688de | |||
| 9ba62e4c8b | |||
| 0dbb576d1b | |||
| 336574571f | |||
| fc93201313 | |||
| fc1a98cbfe | |||
| 3df375e047 | |||
| b060e23a1d | |||
| e92382266f | |||
| 3ca19403fa | |||
| d41d1443a8 | |||
| 89ea56e255 | |||
| 1b24799026 | |||
| 2999f5da47 | |||
| d23b6c2909 | |||
| 6e95c088e4 | |||
| 6113f62ad2 |
14 changed files with 977 additions and 310 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,5 +1,5 @@
|
|||
# Binaries
|
||||
nextworkspace
|
||||
/nextworkspace
|
||||
app/core
|
||||
app/core.exe
|
||||
app/data/*.db
|
||||
|
|
@ -18,6 +18,9 @@ Thumbs.db
|
|||
*.swp
|
||||
*.swo
|
||||
|
||||
# AI / Agent config (stored at project root ~/development/)
|
||||
AGENT.md
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
|
|
|||
100
AGENT.md
100
AGENT.md
|
|
@ -1,100 +0,0 @@
|
|||
# NextWorkspace — Agent Workflow Instructions
|
||||
|
||||
## Versioning
|
||||
|
||||
- Format: `MILESTONE.FEATURE.PATCH.BUILD` (e.g., `0.1.0.0032`)
|
||||
- Bump VERSION file on every change before commit.
|
||||
- Every commit must be tagged with the version: `git tag v$(cat VERSION)`
|
||||
|
||||
## Deployment Model (Unified Script)
|
||||
|
||||
The project uses a single unified script at `tools/nextwks.sh` for all operations.
|
||||
The old `deploy.sh` and `install.sh` are deprecated.
|
||||
|
||||
**Key principles:**
|
||||
- Source of truth is **git remote only**. No local `/opt/NextWks` repo.
|
||||
- Building happens in `/tmp/nextwks-build/` via `git clone --depth 1` (fresh every time).
|
||||
- Runtime goes to `/opt/nextworkspace/` (configs, compose, binary, lng).
|
||||
- Secrets persist in `/opt/backup/.env` and survive `--destroy`.
|
||||
- Stack runs on a shared `nextwks-net` podman bridge network.
|
||||
|
||||
### Script Flags
|
||||
|
||||
| Flag | What it does |
|
||||
|------|-------------|
|
||||
| `--install` | First-time setup on a bare VM: installs deps, prompts for config, builds binary, generates configs, deploys stack |
|
||||
| `--update` | Smart update: clones fresh, rebuilds binary, copies to target, bounces containers |
|
||||
| `--destroy` | Full greenfield redeploy: tears down containers, wipes `/opt/nextworkspace/`, rebuilds from scratch using saved secrets from `/opt/backup/.env` |
|
||||
|
||||
### Workflow for Making Changes
|
||||
|
||||
1. Edit code in the development clone.
|
||||
2. Test locally (e.g., `go build && go run .`).
|
||||
3. Bump `VERSION` (increment BUILD).
|
||||
4. Update `CHANGELOG.md`.
|
||||
5. Commit: `git add -A && git commit -m "description"`
|
||||
6. Tag: `git tag v$(cat VERSION)`
|
||||
7. Push: `git push origin main --tags`
|
||||
8. Deploy: `sudo bash ~/nextwks.sh --update`
|
||||
|
||||
> **Note:** The deploy script is downloaded by users via `curl` from the repo. For production deployment, users run:
|
||||
> ```bash
|
||||
> curl -sL https://git.lohmar.co.uk/lexton-it/NextWks/raw/branch/main/tools/nextwks.sh | sudo bash -s -- --install
|
||||
> ```
|
||||
|
||||
## Build Process
|
||||
|
||||
- Static Go binary: `CGO_ENABLED=0 go build -o nextworkspace .`
|
||||
- Must run on Alpine in the container (no glibc dependency).
|
||||
- Binary runs as PID 1 in the `launcher` container.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Caddy (:80/:443, host ports)
|
||||
├── auth.{DOMAIN} → Authelia :9091 (internal)
|
||||
├── app.{DOMAIN} → Launcher :9000 (internal) with forward auth
|
||||
└── www.{DOMAIN} → static files
|
||||
|
||||
All three containers on nextwks-net (podman bridge).
|
||||
Only Caddy exposes ports to host.
|
||||
```
|
||||
|
||||
## Config Template System
|
||||
|
||||
Config files use `{PLACEHOLDER}` syntax. The script substitutes values at deploy time:
|
||||
|
||||
| File | Placeholders |
|
||||
|------|-------------|
|
||||
| `config/caddy/Caddyfile` | `{DOMAIN}`, `{TLS_EMAIL}` |
|
||||
| `config/authelia/configuration.yml` | `{DOMAIN}`, `{JWT_SECRET}`, `{SESSION_SECRET}`, `{SMTP_HOST}`, `{SMTP_PORT}`, `{SMTP_USER}`, `{SMTP_PASS}` |
|
||||
| `config/authelia/users_database.yml` | `{ADMIN_PASSWORD_HASH}`, `{TLS_EMAIL}` |
|
||||
| `compose/stack.yaml` | `{AUTHELIA_SECRET}` (same as `SESSION_SECRET`) |
|
||||
|
||||
## Env Vault (`/opt/backup/.env`)
|
||||
|
||||
Persisted secrets across destroys:
|
||||
```
|
||||
DOMAIN=nextwks.eu
|
||||
TLS_EMAIL=admin@nextwks.eu
|
||||
ADMIN_USERNAME=master
|
||||
ADMIN_PASSWORD=<generated>
|
||||
SMTP_HOST=smtp.openxchange.eu
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=post@nextwks.eu
|
||||
SMTP_PASS=<prompted>
|
||||
JWT_SECRET=<auto-generated>
|
||||
SESSION_SECRET=<auto-generated>
|
||||
ADMIN_PASSWORD_HASH=<bcrypt hash>
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
After deploy, the script polls `podman exec launcher wget -qO- http://127.0.0.1:9000/health`
|
||||
up to 10 times (2s interval). Expected response: `OK`.
|
||||
|
||||
## Commit Message Style
|
||||
|
||||
- Imperative mood ("Add", "Fix", "Update", "Bump")
|
||||
- Reference the component if relevant ("launcher: add health endpoint")
|
||||
- Keep under 72 chars for the first line
|
||||
105
CHANGELOG.md
105
CHANGELOG.md
|
|
@ -1,5 +1,110 @@
|
|||
# Changelog
|
||||
|
||||
## 0.1.0.0048 — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
- CSV import: loading spinner with "Importing..." message during upload
|
||||
- CSV import: better error display and proper modal close after completion
|
||||
- Admin panel: Import modal shows results and allows closing on success/failure
|
||||
|
||||
## 0.1.0.0046 — 2026-07-11
|
||||
|
||||
### Added
|
||||
- CSV bulk user import in Access tab — download template, fill data, upload
|
||||
- `/api/templates/users.csv` — sample CSV template download
|
||||
- `/api/users/import` — CSV import handler that parses and creates users via authelia-api
|
||||
|
||||
## 0.1.0.0045 — 2026-07-11
|
||||
|
||||
### Changed
|
||||
- MFA enforcement: after saving email in settings, if TOTP is not enabled, a blocking overlay forces the user to set up two-factor on the Authelia portal before proceeding
|
||||
|
||||
## 0.1.0.0044 — 2026-07-11
|
||||
|
||||
### Added
|
||||
- MFA/TOTP check on user settings page — shows setup prompt if no authenticator is configured
|
||||
- `/api/user/mfa-status` endpoint — checks Authelia for TOTP enrollment status
|
||||
|
||||
## 0.1.0.0043 — 2026-07-11
|
||||
|
||||
### Added
|
||||
- Edit user button in Access tab — admin can change email and groups (delete + recreate approach)
|
||||
- Edit user modal with email, groups fields, and new password display
|
||||
|
||||
## 0.1.0.0039 — 2026-07-11
|
||||
|
||||
### Changed
|
||||
- Modernized Authelia config format (fixes all deprecation warnings):
|
||||
- `server.address: tcp://0.0.0.0:9091` (replaces `host` + `port`)
|
||||
- `identity_validation.reset_password.jwt_secret` (replaces `jwt_secret`)
|
||||
- `notifier.smtp.address: submission://...` (replaces `host` + `port`)
|
||||
- `authentication_backend.file.watch: true` (auto-reload on user changes)
|
||||
- `session.remember_me` (replaces `remember_me_duration`)
|
||||
|
||||
## 0.1.0.0038 — 2026-07-11
|
||||
|
||||
### Fixed
|
||||
- Authelia `authentication_backend.file.watch: true` — YAML changes now auto-reload, so new users can log in immediately after creation
|
||||
|
||||
### Investigation: User Onboarding Emails
|
||||
- SMTP config is correct (`notifier.smtp` → `smtp.openxchange.eu:587`)
|
||||
- SMTP connection test passed (TLS handshake successful)
|
||||
- authelia-api does NOT send onboarding emails — returns `placeholder_password` in API response instead
|
||||
- This is an API feature gap, not a configuration issue
|
||||
|
||||
## 0.1.0.0037 — 2026-07-11
|
||||
|
||||
### Changed
|
||||
- Simplified groups model: per-app groups (`drive`, `office`, `chat`, etc.) replaced with `users` + `admins` only
|
||||
- `config/authelia/configuration.yml` — access_control rules reduced from 12 rules to 4
|
||||
- `config/authelia/users_database.yml` — master user groups simplified to `admins`, `users`
|
||||
- `config/nextworkspace/apps.yaml` — all user-facing apps use `groups: ["users"]`
|
||||
- Admin panel user creation form — 9 checkboxes replaced with 2 (User + Admin)
|
||||
|
||||
## 0.1.0.0036 — 2026-07-11
|
||||
|
||||
### Fixed
|
||||
- Admin panel user management: `apiProxyHandler` was stripping `/api` prefix before forwarding to authelia-api, causing 404 on all `/api/users` calls. Removed the `TrimPrefix` — authelia-api expects the full `/api/...` path.
|
||||
|
||||
## 0.1.0.0035 — 2026-07-11
|
||||
|
||||
### Added
|
||||
- `AUTHELIA_API_LISTEN=0.0.0.0:8080` explicitly set in compose (default already correct)
|
||||
|
||||
## 0.1.0.0034 — 2026-07-11
|
||||
|
||||
### Added
|
||||
- Fixed subnet `172.18.0.0/24` for `nextwks-net`
|
||||
- Static IPv4 addresses for all containers (Caddy `.10`, Authelia `.11`, Launcher `.12`)
|
||||
|
||||
### Changed
|
||||
- `compose/stack.yaml`: network config uses `ipv4_address` instead of flat list
|
||||
- `tools/nextwks.sh`: network creation now uses `--subnet 172.18.0.0/24`
|
||||
|
||||
## 0.1.0.0033 — 2026-07-11
|
||||
|
||||
### Added
|
||||
- `tools/firewall-routing.sh` — iptables redirects + VM firewall
|
||||
- `storage.encryption_key` to Authelia config (required by v4.38+)
|
||||
- Auto-detection of existing install in `--install` mode
|
||||
|
||||
### Changed
|
||||
- **Rootless Podman**: all container commands run without sudo
|
||||
- **Ports**: Caddy binds to 8080/8443, iptables redirects 80/443
|
||||
- `.gitignore`: `/nextworkspace` (root-scoped) to track `config/nextworkspace/`
|
||||
- Configs regenerated on every mode (install/update/destroy)
|
||||
- `.env` values single-quoted, written via `tee -a` to preserve `$` in bcrypt hashes
|
||||
- Admin password: now 24 mixed-case alphanumeric chars (base64)
|
||||
- Containers stopped before binary copy to avoid "Text file busy"
|
||||
- Firewall rules persisted via `netfilter-persistent save`
|
||||
- Docs: AGENT.md, README.md fully updated
|
||||
|
||||
### Fixed
|
||||
- `SSL_ERROR_INTERNAL_ERROR_ALERT` — Authelia now starts with proper config
|
||||
- Password hash corruption — `$2a$...` no longer mangled by `bash -c`
|
||||
- "Text file busy" during `--update` — containers stopped before copy
|
||||
- `--update` skipped config regeneration (now always regenerates)
|
||||
|
||||
## 0.1.0.0032 — 2026-07-11
|
||||
|
||||
### Added
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -5,34 +5,37 @@ A self-hosted productivity suite for startups. One binary + Caddy + Authelia.
|
|||
## Architecture
|
||||
|
||||
```
|
||||
app.nextwks.eu :443 auth.nextwks.eu :443
|
||||
│ │
|
||||
Caddy (TLS + forward auth) Caddy → Authelia :9091
|
||||
│ │
|
||||
├── /home/ → launcher page └── authelia-api :8080
|
||||
├── /drive/* → OpenCloud :9100
|
||||
├── /office/* → Euro Office :9200
|
||||
├── /erp/* → ERPNext :9300
|
||||
├── /chat/* → Matrix :9400
|
||||
├── /meet/* → Jitsi :9500
|
||||
├── /mail/* → Alps :9600
|
||||
├── /ai/* → Open WebUI :9700
|
||||
└── /admin/* → Portainer :9800
|
||||
Internet :443 ──iptables──> :8443 ──> Caddy container :443
|
||||
Internet :80 ──iptables──> :8080 ──> Caddy container :80
|
||||
|
||||
Caddy (rootless podman, nextwks-net)
|
||||
├── auth.{DOMAIN} ──> Authelia :9091 (internal)
|
||||
├── app.{DOMAIN} ──> Launcher :9000 (forward auth via Authelia)
|
||||
└── www.{DOMAIN} ──> static files
|
||||
|
||||
Authelia :9091 ──> api :8080 (internal)
|
||||
Launcher :9000 ──> /config, /people, /settings, /health
|
||||
```
|
||||
|
||||
- **Caddy**: Reverse proxy, TLS (auto LE), subdomain routing, forward auth to Authelia
|
||||
- **Authelia**: OIDC provider, 2FA, identity store
|
||||
- **Binary**: Go launcher + path-based reverse proxy to upstream apps
|
||||
- **Caddy**: TLS termination (ZeroSSL/LE), subdomain routing, forward auth to Authelia
|
||||
- **Authelia**: OIDC provider, 2FA, identity store, user management API
|
||||
- **Launcher**: Go binary — app dashboard, people directory, admin panel, settings
|
||||
- **iptables**: Redirects 80→8080 and 443→8443 so Caddy can run rootless
|
||||
|
||||
## Quick Start (Bare VM)
|
||||
|
||||
```bash
|
||||
curl -sL https://git.lohmar.co.uk/lexton-it/NextWks/raw/branch/main/tools/nextwks.sh \
|
||||
| sudo bash -s -- --install
|
||||
# Download the script to your home folder
|
||||
curl -o ~/nextwks.sh https://git.lohmar.co.uk/lexton-it/NextWks/raw/branch/main/tools/nextwks.sh
|
||||
chmod +x ~/nextwks.sh
|
||||
|
||||
# Run the installer (no sudo — it'll ask only where needed)
|
||||
./nextwks.sh --install
|
||||
```
|
||||
|
||||
Prompts for domain, TLS email, and admin credentials. Installs deps (Go, Podman, git),
|
||||
clones repo to `/tmp/nextwks-build/`, builds binary, generates configs, deploys stack.
|
||||
The script stays in `~/nextwks.sh` for future updates.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
|
|
@ -61,15 +64,11 @@ clones repo to `/tmp/nextwks-build/`, builds binary, generates configs, deploys
|
|||
## Operations
|
||||
|
||||
```bash
|
||||
# First-time install (download + run — saves itself to ~/nextwks.sh)
|
||||
curl -sL https://git.lohmar.co.uk/lexton-it/NextWks/raw/branch/main/tools/nextwks.sh \
|
||||
| sudo bash -s -- --install
|
||||
|
||||
# Smart update (pull, build, copy, restart)
|
||||
sudo bash ~/nextwks.sh --update
|
||||
./nextwks.sh --update
|
||||
|
||||
# Full redeploy (tear down, rebuild from scratch with saved secrets)
|
||||
sudo bash ~/nextwks.sh --destroy
|
||||
./nextwks.sh --destroy
|
||||
```
|
||||
|
||||
## Workflow (Development)
|
||||
|
|
@ -77,7 +76,7 @@ sudo bash ~/nextwks.sh --destroy
|
|||
1. Edit code in your clone.
|
||||
2. Bump `VERSION`, update `CHANGELOG.md`.
|
||||
3. `git commit -m "message" && git tag v$(cat VERSION) && git push origin main --tags`
|
||||
4. On the server: `sudo bash ~/nextwks.sh --update`
|
||||
4. On the server: `./nextwks.sh --update`
|
||||
|
||||
The script clones fresh from git every time — no stale repos, no permissions issues.
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.1.0.0032
|
||||
0.1.0.0048
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ services:
|
|||
container_name: caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:80"
|
||||
- "8443:443"
|
||||
volumes:
|
||||
- /opt/nextworkspace/config/caddy/:/etc/caddy/
|
||||
- /opt/nextworkspace/data/caddy/:/data/:Z
|
||||
|
|
@ -17,7 +17,8 @@ services:
|
|||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- nextwks-net
|
||||
nextwks-net:
|
||||
ipv4_address: 172.18.0.10
|
||||
|
||||
authelia:
|
||||
image: git24hcom/authelia:latest
|
||||
|
|
@ -26,6 +27,9 @@ services:
|
|||
expose:
|
||||
- "9091"
|
||||
- "8080"
|
||||
environment:
|
||||
- TZ=UTC
|
||||
- AUTHELIA_API_LISTEN=0.0.0.0:8080
|
||||
volumes:
|
||||
- /opt/nextworkspace/config/authelia/:/config/
|
||||
- /opt/nextworkspace/data/authelia/:/data/
|
||||
|
|
@ -35,7 +39,8 @@ services:
|
|||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- nextwks-net
|
||||
nextwks-net:
|
||||
ipv4_address: 172.18.0.11
|
||||
|
||||
launcher:
|
||||
image: alpine:latest
|
||||
|
|
@ -46,17 +51,24 @@ services:
|
|||
volumes:
|
||||
- /opt/nextworkspace/:/opt/nextworkspace/
|
||||
working_dir: /opt/nextworkspace
|
||||
command: /opt/nextworkspace/nextworkspace
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "apk add --no-cache curl sqlite >/dev/null 2>&1 && exec /opt/nextworkspace/nextworkspace"
|
||||
environment:
|
||||
- CONFIG_DIR=/opt/nextworkspace/config/nextworkspace
|
||||
- AUTHELIA_SECRET={AUTHELIA_SECRET}
|
||||
- DOMAIN={DOMAIN}
|
||||
- TLS_EMAIL={TLS_EMAIL}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9000/health"]
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:9000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- nextwks-net
|
||||
nextwks-net:
|
||||
ipv4_address: 172.18.0.12
|
||||
|
||||
networks:
|
||||
nextwks-net:
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
###############################################################
|
||||
# Authelia configuration #
|
||||
###############################################################
|
||||
host: 0.0.0.0
|
||||
port: 9091
|
||||
server:
|
||||
address: tcp://0.0.0.0:9091
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
theme: dark
|
||||
|
||||
certificates_directory: /config/certs/
|
||||
|
||||
jwt_secret: {JWT_SECRET}
|
||||
identity_validation:
|
||||
reset_password:
|
||||
jwt_secret: {JWT_SECRET}
|
||||
|
||||
default_redirection_url: https://app.{DOMAIN}/
|
||||
|
||||
|
|
@ -35,79 +35,29 @@ access_control:
|
|||
- "group:admins"
|
||||
policy: one_factor
|
||||
|
||||
# App paths — group-restricted
|
||||
# Users with TFA enforcement — two-factor required
|
||||
- domain: "app.{DOMAIN}"
|
||||
resources:
|
||||
- "^/drive(/.*)?$"
|
||||
subject:
|
||||
- "group:admins"
|
||||
- "group:drive"
|
||||
policy: one_factor
|
||||
- "group:tfa_required"
|
||||
policy: two_factor
|
||||
|
||||
- domain: "app.{DOMAIN}"
|
||||
resources:
|
||||
- "^/office(/.*)?$"
|
||||
subject:
|
||||
- "group:admins"
|
||||
- "group:office"
|
||||
policy: one_factor
|
||||
|
||||
- domain: "app.{DOMAIN}"
|
||||
resources:
|
||||
- "^/enterprise(/.*)?$"
|
||||
subject:
|
||||
- "group:admins"
|
||||
- "group:erp"
|
||||
policy: one_factor
|
||||
|
||||
- domain: "app.{DOMAIN}"
|
||||
resources:
|
||||
- "^/chat(/.*)?$"
|
||||
subject:
|
||||
- "group:admins"
|
||||
- "group:chat"
|
||||
policy: one_factor
|
||||
|
||||
- domain: "app.{DOMAIN}"
|
||||
resources:
|
||||
- "^/meet(/.*)?$"
|
||||
subject:
|
||||
- "group:admins"
|
||||
- "group:meet"
|
||||
policy: one_factor
|
||||
|
||||
- domain: "app.{DOMAIN}"
|
||||
resources:
|
||||
- "^/connect(/.*)?$"
|
||||
subject:
|
||||
- "group:admins"
|
||||
- "group:mail"
|
||||
policy: one_factor
|
||||
|
||||
- domain: "app.{DOMAIN}"
|
||||
resources:
|
||||
- "^/aida(/.*)?$"
|
||||
subject:
|
||||
- "group:admins"
|
||||
- "group:ai"
|
||||
policy: one_factor
|
||||
|
||||
# Home/launcher — any authenticated user
|
||||
# Everything else — any authenticated user
|
||||
- domain: "app.{DOMAIN}"
|
||||
policy: one_factor
|
||||
|
||||
authentication_backend:
|
||||
file:
|
||||
path: /config/users_database.yml
|
||||
watch: true
|
||||
|
||||
session:
|
||||
name: nextworkspace_session
|
||||
secret: {SESSION_SECRET}
|
||||
domain: {DOMAIN}
|
||||
domain: "{DOMAIN}"
|
||||
same_site: lax
|
||||
expiration: 1h
|
||||
inactivity: 5m
|
||||
remember_me_duration: 1M
|
||||
remember_me: 1M
|
||||
|
||||
regulation:
|
||||
max_retries: 5
|
||||
|
|
@ -115,6 +65,7 @@ regulation:
|
|||
ban_time: 5m
|
||||
|
||||
storage:
|
||||
encryption_key: {STORAGE_ENCRYPTION_KEY}
|
||||
local:
|
||||
path: /data/db.sqlite
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,3 @@ users:
|
|||
groups:
|
||||
- admins
|
||||
- users
|
||||
- drive
|
||||
- office
|
||||
- erp
|
||||
- chat
|
||||
- meet
|
||||
- mail
|
||||
- ai
|
||||
|
|
|
|||
59
config/nextworkspace/apps.yaml
Normal file
59
config/nextworkspace/apps.yaml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
apps:
|
||||
- name: "NextWks Core"
|
||||
subtitle: "Launcher & Workspace Hub"
|
||||
path: "/home"
|
||||
icon: "home"
|
||||
groups: ["users"]
|
||||
- name: "OpenCloud"
|
||||
subtitle: "File Storage"
|
||||
path: "/drive"
|
||||
upstream: "http://127.0.0.1:9100"
|
||||
icon: "cloud"
|
||||
groups: ["users"]
|
||||
- name: "Euro Office"
|
||||
subtitle: "Collaborative Suite"
|
||||
path: "/office"
|
||||
upstream: "http://127.0.0.1:9200"
|
||||
icon: "office"
|
||||
groups: ["users"]
|
||||
- name: "ERPNext"
|
||||
subtitle: "Enterprise ERP"
|
||||
path: "/enterprise"
|
||||
upstream: "http://127.0.0.1:9300"
|
||||
icon: "erp"
|
||||
groups: ["users"]
|
||||
- name: "Matrix Chat"
|
||||
subtitle: "Team Communication"
|
||||
path: "/chat"
|
||||
upstream: "http://127.0.0.1:9400"
|
||||
icon: "chat"
|
||||
groups: ["users"]
|
||||
- name: "Jitsi"
|
||||
subtitle: "Video Conferencing"
|
||||
path: "/meet"
|
||||
upstream: "http://127.0.0.1:9500"
|
||||
icon: "meet"
|
||||
groups: ["users"]
|
||||
- name: "Webmail"
|
||||
subtitle: "Email Client"
|
||||
path: "/connect"
|
||||
upstream: "http://127.0.0.1:9600"
|
||||
icon: "mail"
|
||||
groups: ["users"]
|
||||
- name: "AI Chat"
|
||||
subtitle: "Open WebUI"
|
||||
path: "/aida"
|
||||
upstream: "http://127.0.0.1:9700"
|
||||
icon: "ai"
|
||||
groups: ["users"]
|
||||
- name: "Portainer"
|
||||
subtitle: "Container Management"
|
||||
path: "/admin"
|
||||
upstream: "http://127.0.0.1:9800"
|
||||
icon: "admin"
|
||||
groups: ["admins"]
|
||||
- name: "Admin Panel"
|
||||
subtitle: "Workspace Configuration"
|
||||
path: "/config"
|
||||
icon: "settings"
|
||||
groups: ["admins"]
|
||||
7
config/nextworkspace/config.yaml
Normal file
7
config/nextworkspace/config.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
server:
|
||||
port: 9000
|
||||
host: "0.0.0.0"
|
||||
|
||||
app:
|
||||
name: "NextWorkspace"
|
||||
description: "Your Self-Hosted Workspace"
|
||||
14
config/nextworkspace/settings.yaml
Normal file
14
config/nextworkspace/settings.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
company:
|
||||
name: "NextWorkspace"
|
||||
subtitle: "Your Self-Hosted Workspace for Startups"
|
||||
logo: ""
|
||||
language: "en"
|
||||
timezone: "UTC"
|
||||
smtp:
|
||||
host: ""
|
||||
port: 587
|
||||
user: ""
|
||||
sender: ""
|
||||
imap:
|
||||
host: ""
|
||||
port: 993
|
||||
493
main.go
493
main.go
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
|
@ -616,7 +617,7 @@ func apiProxyHandler(w http.ResponseWriter, r *http.Request) {
|
|||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
|
||||
// Forward the full path (authelia-api expects /api/... prefix)
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
|
|
@ -712,7 +713,25 @@ func globalSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func applySettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cmd := exec.Command("/opt/NextWks/deploy.sh")
|
||||
// Find nextwks.sh in common locations
|
||||
scriptPaths := []string{
|
||||
"/home/master/nextwks.sh",
|
||||
"/root/nextwks.sh",
|
||||
"/opt/backup/nextwks.sh",
|
||||
}
|
||||
script := ""
|
||||
for _, p := range scriptPaths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
script = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if script == "" {
|
||||
http.Error(w, `{"success":false,"error":"nextwks.sh not found"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command("bash", script, "--update")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Apply failed: %v\n%s", err, string(output)), http.StatusInternalServerError)
|
||||
|
|
@ -735,6 +754,225 @@ func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||
settings.Company.Name, settings.Company.Subtitle, settings.Company.Logo)
|
||||
}
|
||||
|
||||
// --- CSV handlers ---
|
||||
|
||||
func csvTemplateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=users-template.csv")
|
||||
|
||||
// BOM for Excel compatibility
|
||||
w.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
fmt.Fprintln(w, "username,display_name,email,is_admin")
|
||||
fmt.Fprintln(w, "jane.doe,Jane Doe,jane@example.com,no")
|
||||
fmt.Fprintln(w, "john.smith,John Smith,john@example.com,yes")
|
||||
fmt.Fprintln(w, "# is_admin: yes = admin access, no = regular user. Leave empty for regular user.")
|
||||
}
|
||||
|
||||
func csvImportHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseMultipartForm(10 << 20)
|
||||
if err != nil {
|
||||
http.Error(w, "File too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("csv_file")
|
||||
if err != nil {
|
||||
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid CSV format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(records) < 2 {
|
||||
http.Error(w, "CSV must have a header row and at least one data row", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
type BulkUser struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
var users []BulkUser
|
||||
var errors []string
|
||||
|
||||
for i, row := range records[1:] {
|
||||
line := i + 2
|
||||
if len(row) < 3 {
|
||||
errors = append(errors, fmt.Sprintf("Line %d: missing fields", line))
|
||||
continue
|
||||
}
|
||||
username := strings.TrimSpace(row[0])
|
||||
if username == "" || strings.HasPrefix(username, "#") {
|
||||
continue
|
||||
}
|
||||
isAdmin := strings.ToLower(strings.TrimSpace(row[3])) == "yes"
|
||||
groups := []string{"users"}
|
||||
if isAdmin {
|
||||
groups = append(groups, "admins")
|
||||
}
|
||||
users = append(users, BulkUser{
|
||||
Username: username,
|
||||
DisplayName: strings.TrimSpace(row[1]),
|
||||
Email: strings.TrimSpace(row[2]),
|
||||
Groups: groups,
|
||||
})
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "No valid users found in CSV",
|
||||
"errors": errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"users": users})
|
||||
token := os.Getenv("AUTHELIA_SECRET")
|
||||
req, _ := http.NewRequest("POST", "http://authelia:8080/api/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to contact authelia-api", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"api_result": result,
|
||||
"parse_errors": errors,
|
||||
})
|
||||
}
|
||||
|
||||
// --- MFA enforcement ---
|
||||
|
||||
// Check if user has TOTP enrolled by querying Authelia's SQLite database directly.
|
||||
func checkTOTPEnrolled(username string) bool {
|
||||
dbPath := "/opt/nextworkspace/data/authelia/db.sqlite"
|
||||
out, err := exec.Command("sqlite3", dbPath,
|
||||
"SELECT COUNT(*) FROM totp_configurations WHERE username='"+username+"'").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "1"
|
||||
}
|
||||
|
||||
// Set user's preferred 2FA method to totp, triggering enrollment prompt on next login.
|
||||
func enforceTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Header.Get("Remote-User")
|
||||
if user == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if checkTOTPEnrolled(user) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "already_enrolled",
|
||||
"totp_required": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Try the new authelia-api policy endpoint first (if deployed)
|
||||
token := os.Getenv("AUTHELIA_SECRET")
|
||||
policyBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "TOTP enforcement for " + user,
|
||||
"domain": []string{"*"},
|
||||
"subjects": []string{"user:" + user},
|
||||
"policy": "two_factor",
|
||||
})
|
||||
apiReq, _ := http.NewRequest("POST", "http://authelia:8080/api/policies", bytes.NewReader(policyBody))
|
||||
apiReq.Header.Set("Authorization", "Bearer "+token)
|
||||
apiReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
apiResp, apiErr := http.DefaultClient.Do(apiReq)
|
||||
apiOk := apiErr == nil && apiResp != nil && apiResp.StatusCode == 201
|
||||
|
||||
if apiOk {
|
||||
apiResp.Body.Close()
|
||||
}
|
||||
|
||||
// Set user_preference regardless (triggers Authelia's enrollment prompt on next login)
|
||||
exec.Command("sqlite3", "/opt/nextworkspace/data/authelia/db.sqlite",
|
||||
"INSERT OR REPLACE INTO user_preferences (username, method) VALUES ('"+user+"', 'totp')").Run()
|
||||
|
||||
// Add user to tfa_required group (enforces two_factor via access_control)
|
||||
token = os.Getenv("AUTHELIA_SECRET")
|
||||
userReq, _ := http.NewRequest("GET", "http://authelia:8080/api/users/"+user, nil)
|
||||
userReq.Header.Set("Authorization", "Bearer "+token)
|
||||
if userResp, err := http.DefaultClient.Do(userReq); err == nil && userResp.StatusCode == 200 {
|
||||
var ud struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
json.NewDecoder(userResp.Body).Decode(&ud)
|
||||
userResp.Body.Close()
|
||||
|
||||
hasTFA := false
|
||||
for _, g := range ud.Groups {
|
||||
if g == "tfa_required" {
|
||||
hasTFA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTFA {
|
||||
ud.Groups = append(ud.Groups, "tfa_required")
|
||||
body, _ := json.Marshal(map[string]interface{}{"users": []interface{}{ud}})
|
||||
|
||||
delR, _ := http.NewRequest("DELETE", "http://authelia:8080/api/users/"+user, nil)
|
||||
delR.Header.Set("Authorization", "Bearer "+token)
|
||||
http.DefaultClient.Do(delR)
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
crR, _ := http.NewRequest("POST", "http://authelia:8080/api/users/bulk", bytes.NewReader(body))
|
||||
crR.Header.Set("Authorization", "Bearer "+token)
|
||||
crR.Header.Set("Content-Type", "application/json")
|
||||
http.DefaultClient.Do(crR)
|
||||
}
|
||||
}
|
||||
|
||||
if apiOk {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "enforced",
|
||||
"totp_required": true,
|
||||
"policy_created": true,
|
||||
})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "enforced",
|
||||
"totp_required": true,
|
||||
"policy_created": false,
|
||||
})
|
||||
}
|
||||
if apiResp != nil {
|
||||
apiResp.Body.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- Translation system ---
|
||||
|
||||
var translations = make(map[string]map[string]string)
|
||||
|
|
@ -936,6 +1174,8 @@ const settingsHTML = `<!DOCTYPE html>
|
|||
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.88rem; font-weight: 500; cursor: pointer; border: none; }
|
||||
.btn-primary { background: #1a1a2e; color: #fff; }
|
||||
.btn-primary:hover { background: #2d3748; }
|
||||
.btn-secondary { display: inline-block; background: #1a1a2e; color: #fff; padding: 8px 20px; border-radius: 6px; text-decoration: none; margin-top: 0.5rem; }
|
||||
.btn-secondary:hover { background: #2d3748; }
|
||||
.actions { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; }
|
||||
.saved-msg { color: #48bb78; font-size: 0.9rem; display: none; }
|
||||
</style>
|
||||
|
|
@ -1003,6 +1243,8 @@ const settingsHTML = `<!DOCTYPE html>
|
|||
<span id="savemsg" class="saved-msg">{{t .Lang "saved"}}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="mfa-msg" style="display:none;margin-top:1rem;padding:1rem;border-radius:8px;"></div>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<div class="card">
|
||||
<h2>Administration</h2>
|
||||
|
|
@ -1017,8 +1259,38 @@ const settingsHTML = `<!DOCTYPE html>
|
|||
const form = document.getElementById('settings-form');
|
||||
const data = new FormData(form);
|
||||
const resp = await fetch('/settings/save', {method:'POST', body:new URLSearchParams(data)});
|
||||
const msg = document.getElementById('savemsg');
|
||||
if (resp.ok) { msg.style.display = 'inline'; setTimeout(() => msg.style.display = 'none', 3000); }
|
||||
const savemsg = document.getElementById('savemsg');
|
||||
if (resp.ok) { savemsg.style.display = 'inline'; setTimeout(() => savemsg.style.display = 'none', 3000); }
|
||||
|
||||
// If user has an email, enforce TOTP enrollment
|
||||
const emailField = document.querySelector('input[name="email"]');
|
||||
if (emailField && emailField.value) {
|
||||
setTimeout(async () => {
|
||||
const enforce = await fetch('/api/user/enforce-totp');
|
||||
const result = await enforce.json();
|
||||
const msgDiv = document.getElementById('mfa-msg');
|
||||
if (result.totp_required && result.status === 'enforced') {
|
||||
msgDiv.style.display = 'block';
|
||||
msgDiv.style.background = '#fffbeb';
|
||||
msgDiv.style.border = '1px solid #fde68a';
|
||||
msgDiv.style.color = '#92400e';
|
||||
msgDiv.innerHTML = '<strong>🔐 Two-Factor Setup Required:</strong> Please visit <a href="https://auth.nextwks.eu" style="color:#3182ce;" target="_blank">the Authelia portal</a>, log in, and set up an authenticator app (Google Authenticator, Authy, etc.) under Security → Two-Factor. This is required after adding a work email.';
|
||||
} else if (result.totp_required && result.status === 'error') {
|
||||
msgDiv.style.display = 'block';
|
||||
msgDiv.style.background = '#fff5f5';
|
||||
msgDiv.style.border = '1px solid #fed7d7';
|
||||
msgDiv.style.color = '#9b2c2c';
|
||||
msgDiv.innerHTML = '<strong>⚠️ Could not enforce two-factor:</strong> ' + (result.error || 'Unknown error');
|
||||
} else if (!result.totp_required && result.status === 'already_enrolled') {
|
||||
msgDiv.style.display = 'block';
|
||||
msgDiv.style.background = '#f0fff4';
|
||||
msgDiv.style.border = '1px solid #c6f6d5';
|
||||
msgDiv.style.color = '#276749';
|
||||
msgDiv.innerHTML = '✅ Two-factor authentication is already active. Your account is secure.';
|
||||
setTimeout(() => { msgDiv.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1298,6 +1570,8 @@ const adminHTML = `<!DOCTYPE html>
|
|||
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 0.9rem; border-radius: 6px; font-size: 0.85rem; font-weight: 500; cursor: pointer; border: none; text-decoration: none; transition: all .12s; }
|
||||
.btn-primary { background: #1a1a2e; color: #fff; }
|
||||
.btn-primary:hover { background: #2d3748; }
|
||||
.btn-edit { background: #fff; color: #3182ce; border: 1px solid #bee3f8; }
|
||||
.btn-edit:hover { background: #ebf8ff; }
|
||||
.btn-danger { background: #fff; color: #e53e3e; border: 1px solid #fed7d7; }
|
||||
.btn-danger:hover { background: #fff5f5; }
|
||||
.btn-ghost { background: transparent; color: #718096; border: 1px solid #e2e8f0; }
|
||||
|
|
@ -1323,6 +1597,8 @@ const adminHTML = `<!DOCTYPE html>
|
|||
.error { background: #fed7d7; color: #c53030; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.88rem; border: 1px solid #feb2b2; }
|
||||
.password-box { background: #1a1a2e; color: #63b3ed; padding: 0.65rem 1rem; border-radius: 6px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.5rem; display: inline-block; }
|
||||
.hidden { display: none; }
|
||||
.btn-secondary { background: #fff; color: #1a1a2e; padding: 8px 20px; border: 1px solid #1a1a2e; border-radius: 6px; cursor: pointer; font-size:0.88rem; }
|
||||
.btn-secondary:hover { background: #f7fafc; }
|
||||
.empty-state { text-align: center; padding: 2.5rem 1rem; color: #a0aec0; }
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
||||
.empty-state p { font-size: 0.9rem; }
|
||||
|
|
@ -1408,7 +1684,10 @@ const adminHTML = `<!DOCTYPE html>
|
|||
<div id="page-access" class="page hidden">
|
||||
<div class="page-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div><h2>Access Management</h2><p>Manage users, groups, and authentication policies.</p></div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button class="btn btn-primary" onclick="showCreateModal()">+ Add User</button>
|
||||
<button class="btn btn-secondary" onclick="showImportModal()">📥 Import CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#fff;border-radius:10px;border:1px solid #edf2f7;overflow:hidden;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
|
|
@ -1427,16 +1706,9 @@ const adminHTML = `<!DOCTYPE html>
|
|||
<div class="field"><label>Display Name</label><input type="text" name="display_name" required></div>
|
||||
<div class="field"><label>Email</label><input type="email" name="email" required></div>
|
||||
<div class="field"><label>Groups</label>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.4rem;">
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="users" checked> Users</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="drive"> Drive</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="office"> Office</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="erp"> ERP</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="chat"> Chat</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="meet"> Meet</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="mail"> Mail</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="ai"> AI</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="admins"> Admin</label>
|
||||
<div class="checkbox-group">
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="users" checked> User (access to all apps)</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="admins"> Admin (access to config panel)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
|
||||
|
|
@ -1448,12 +1720,63 @@ const adminHTML = `<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit User Modal -->
|
||||
<div id="edit-user-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;">
|
||||
<div style="background:#fff;border-radius:12px;padding:2rem;width:500px;max-width:90%;margin:5vh auto;">
|
||||
<h3 style="margin-bottom:1.5rem;">Edit User</h3>
|
||||
<p id="edit-username-display" style="font-weight:600;margin-bottom:1rem;"></p>
|
||||
<form id="edit-user-form" onsubmit="return saveEditUser(event)">
|
||||
<input type="hidden" name="edit_username" id="edit-username">
|
||||
<div class="field"><label>Email</label><input type="email" name="edit_email" id="edit-email" required></div>
|
||||
<div class="field"><label>Groups</label>
|
||||
<div class="checkbox-group">
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="edit_groups" value="users" checked> User (access to all apps)</label>
|
||||
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="edit_groups" value="admins"> Admin (access to config panel)</label>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#718096;font-size:0.82rem;margin:0.5rem 0;">The user will receive a new generated password. Share it with them.</p>
|
||||
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="closeEditUserModal()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="edit-result" style="display:none;margin-top:1rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security -->
|
||||
<div id="page-security" class="page hidden">
|
||||
<div class="page-header"><h2>Security</h2><p>Authentication policies, tokens, and session configuration.</p></div>
|
||||
<div class="card"><h3>Authentication</h3><p style="color:#718096;font-size:0.9rem;">Configured via Authelia. Policies enforced at the proxy level by Caddy.</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Import CSV Modal -->
|
||||
<div id="import-csv-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;">
|
||||
<div style="background:#fff;border-radius:12px;padding:2rem;width:550px;max-width:90%;margin:5vh auto;position:relative;">
|
||||
<div id="import-loading" style="display:none;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(255,255,255,0.85);border-radius:12px;z-index:10;align-items:center;justify-content:center;flex-direction:column;">
|
||||
<div style="width:40px;height:40px;border:4px solid #e2e8f0;border-top-color:#1a1a2e;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>
|
||||
<p style="font-weight:600;color:#1a1a2e;">{{t .Lang "importing"}}</p>
|
||||
</div>
|
||||
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
||||
<h3 style="margin-bottom:1.5rem;">{{t .Lang "nav_access"}} — CSV Import</h3>
|
||||
<div id="import-step1" style="background:#f7fafc;padding:1rem;border-radius:8px;margin-bottom:1rem;">
|
||||
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>1.</strong> <a href="/api/templates/users.csv" download style="color:#3182ce;">Download CSV template</a></p>
|
||||
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>2.</strong> Fill in user data (Excel, LibreOffice, or text editor)</p>
|
||||
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>3.</strong> Upload the completed file</p>
|
||||
</div>
|
||||
<form id="csv-import-form" onsubmit="return importCSV(event)">
|
||||
<div id="import-form-fields">
|
||||
<div class="field"><label>CSV File</label><input type="file" name="csv_file" accept=".csv" required style="width:100%;"></div>
|
||||
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" id="import-btn">{{t .Lang "import"}}</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="closeImportModal()">{{t .Lang "cancel"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="import-results" style="display:none;margin-top:1rem;"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain -->
|
||||
<div id="page-domain" class="page hidden">
|
||||
<div class="page-header"><h2>Domain</h2><p>Domain mapping, email configuration, and network settings.</p></div>
|
||||
|
|
@ -1516,8 +1839,9 @@ const adminHTML = `<!DOCTYPE html>
|
|||
tbody.innerHTML = users.map(u => {
|
||||
const groups = (u.groups||[]).map(g => '<span class="badge">' + esc(g) + '</span>').join(' ');
|
||||
const status = u.disabled ? '<span style="color:#e53e3e;font-weight:500;">Disabled</span>' : '<span style="color:#38a169;font-weight:500;">Active</span>';
|
||||
const del = u.username === 'master' ? '<button class="btn btn-danger btn-sm" disabled title="Cannot delete master">Delete</button>' : '<button class="btn btn-danger btn-sm" onclick="deleteUser(\'' + u.username + '\')">Delete</button>';
|
||||
return '<tr><td style="padding:12px 16px;border-top:1px solid #edf2f7;"><strong>' + esc(u.username) + '</strong></td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + esc(u.display_name||'') + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + esc(u.email||'') + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + groups + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + status + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + del + '</td></tr>';
|
||||
const editBtn = '<button class="btn btn-edit btn-sm" onclick="editUser(\'' + u.username + '\',\'' + esc(u.email||'') + '\',\'' + (u.groups||[]).join(',') + '\')">Edit</button>';
|
||||
const delBtn = u.username === 'master' ? '<button class="btn btn-danger btn-sm" disabled title="Cannot delete master">Delete</button>' : '<button class="btn btn-danger btn-sm" onclick="deleteUser(\'' + u.username + '\')">Delete</button>';
|
||||
return '<tr><td style="padding:12px 16px;border-top:1px solid #edf2f7;"><strong>' + esc(u.username) + '</strong></td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + esc(u.display_name||'') + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + esc(u.email||'') + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + groups + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + status + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + editBtn + ' ' + delBtn + '</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
|
@ -1549,9 +1873,143 @@ const adminHTML = `<!DOCTYPE html>
|
|||
else alert('Failed to delete user');
|
||||
}
|
||||
|
||||
function editUser(username, email, groups) {
|
||||
document.getElementById('edit-username').value = username;
|
||||
document.getElementById('edit-username-display').textContent = 'Editing: ' + username;
|
||||
document.getElementById('edit-email').value = email;
|
||||
const groupList = groups.split(',');
|
||||
document.querySelectorAll('#edit-user-form input[name="edit_groups"]').forEach(cb => {
|
||||
cb.checked = groupList.includes(cb.value);
|
||||
});
|
||||
document.getElementById('edit-result').style.display = 'none';
|
||||
document.getElementById('edit-user-modal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeEditUserModal() {
|
||||
document.getElementById('edit-user-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveEditUser(event) {
|
||||
event.preventDefault();
|
||||
const username = document.getElementById('edit-username').value;
|
||||
const email = document.getElementById('edit-email').value;
|
||||
const groups = [];
|
||||
document.querySelectorAll('#edit-user-form input[name="edit_groups"]:checked').forEach(cb => {
|
||||
groups.push(cb.value);
|
||||
});
|
||||
|
||||
// Save original user data before deleting (safety net)
|
||||
let originalData = null;
|
||||
try {
|
||||
const origResp = await fetch('/api/users/' + username);
|
||||
if (origResp.ok) originalData = await origResp.json();
|
||||
} catch(e) {}
|
||||
|
||||
// 1. Delete user
|
||||
const delResp = await fetch('/api/users/' + username, { method: 'DELETE' });
|
||||
if (!delResp.ok) { alert('Failed to delete user for re-creation'); return; }
|
||||
|
||||
// 2. Recreate with retry (API SQLite can be busy after delete)
|
||||
const body = JSON.stringify({
|
||||
users: [{
|
||||
username: username,
|
||||
display_name: username,
|
||||
email: email,
|
||||
groups: groups
|
||||
}]
|
||||
});
|
||||
|
||||
let createResp, result;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
createResp = await fetch('/api/users/bulk', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: body
|
||||
});
|
||||
result = await createResp.json();
|
||||
if (createResp.ok && result.users && result.users[0]) break;
|
||||
}
|
||||
|
||||
const resultDiv = document.getElementById('edit-result');
|
||||
resultDiv.style.display = 'block';
|
||||
|
||||
if (createResp.ok && result.users && result.users[0]) {
|
||||
const pwd = result.users[0].placeholder_password || '(unchanged)';
|
||||
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#f0fff4;border:1px solid #c6f6d5;border-radius:8px;color:#276749;font-size:0.88rem;">✅ User updated.<br>New password: <code style="background:#edf2f7;padding:0.15rem 0.4rem;border-radius:4px;font-size:0.82rem;">' + pwd + '</code><br>Share this with the user.</div>';
|
||||
closeEditUserModal();
|
||||
loadUsers();
|
||||
} else {
|
||||
// Restore original user if available
|
||||
if (originalData && originalData.username) {
|
||||
const restoreBody = JSON.stringify({users:[{username:originalData.username,display_name:originalData.display_name||originalData.username,email:originalData.email||'',groups:originalData.groups||['users']}]});
|
||||
await fetch('/api/users/bulk', {method:'POST', headers:{'Content-Type':'application/json'}, body:restoreBody});
|
||||
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;">❌ Update failed. The user has been restored to their original state. Error: ' + JSON.stringify(result) + '</div>';
|
||||
loadUsers();
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;">❌ FAILED to recreate user. The user was deleted but could not be recreated. Please manually add the user again. Error: ' + JSON.stringify(result) + '</div>';
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function showCreateModal() { document.getElementById('createModal').style.display = 'block'; }
|
||||
function closeCreateModal() { document.getElementById('createModal').style.display = 'none'; }
|
||||
|
||||
function showImportModal() {
|
||||
document.getElementById('import-csv-modal').style.display = 'block';
|
||||
document.getElementById('import-results').style.display = 'none';
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
document.getElementById('import-csv-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function importCSV(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Show loading spinner
|
||||
document.getElementById('import-form-fields').style.display = 'none';
|
||||
document.getElementById('import-step1').style.display = 'none';
|
||||
document.getElementById('import-loading').style.display = 'flex';
|
||||
document.querySelector('#import-csv-modal h3').textContent = 'Importing...';
|
||||
|
||||
const form = document.getElementById('csv-import-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const resp = await fetch('/api/users/import', { method: 'POST', body: formData });
|
||||
const result = await resp.json();
|
||||
const resultsDiv = document.getElementById('import-results');
|
||||
|
||||
// Hide loading
|
||||
document.getElementById('import-loading').style.display = 'none';
|
||||
resultsDiv.style.display = 'block';
|
||||
|
||||
if (result.api_result && result.api_result.success) {
|
||||
const created = result.api_result.created || 0;
|
||||
let html = '<div style="background:#f0fff4;color:#276749;padding:1rem;border-radius:8px;margin-bottom:0.5rem;">✅ ' + created + ' users created successfully.</div>';
|
||||
if (result.api_result.users && result.api_result.users.length > 0) {
|
||||
html += '<table style="width:100%;border-collapse:collapse;"><tr style="background:#f7fafc;"><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">User</th><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">Password</th></tr>';
|
||||
result.api_result.users.forEach(u => {
|
||||
html += '<tr><td style="padding:6px 12px;border:1px solid #e2e8f0;">' + u.username + '</td><td style="padding:6px 12px;border:1px solid #e2e8f0;"><code style="background:#edf2f7;padding:2px 6px;border-radius:4px;font-size:0.85rem;">' + (u.placeholder_password || '—') + '</code></td></tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
}
|
||||
html += '<div style="margin-top:1rem;"><button class="btn btn-primary" onclick="closeImportModal(); loadUsers();">Done</button></div>';
|
||||
resultsDiv.innerHTML = html;
|
||||
} else {
|
||||
let errorMsg = result.error || 'Unknown error';
|
||||
if (result.api_result && result.api_result.error) errorMsg = result.api_result.error;
|
||||
let html = '<div style="background:#fff5f5;color:#9b2c2c;padding:1rem;border-radius:8px;margin-bottom:0.5rem;">❌ Import failed: ' + errorMsg + '</div>';
|
||||
if (result.parse_errors && result.parse_errors.length) {
|
||||
html += '<ul style="color:#9b2c2c;font-size:0.88rem;">' + result.parse_errors.map(e => '<li>' + e + '</li>').join('') + '</ul>';
|
||||
}
|
||||
html += '<div style="margin-top:1rem;"><button class="btn btn-ghost" onclick="closeImportModal()">Close</button></div>';
|
||||
resultsDiv.innerHTML = html;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.querySelector('[data-section="access"].active')) loadUsers();
|
||||
</script>
|
||||
</body>
|
||||
|
|
@ -1605,6 +2063,9 @@ func main() {
|
|||
// Public
|
||||
mux.HandleFunc("/health", healthHandler)
|
||||
mux.HandleFunc("/api/settings/public", publicSettingsHandler)
|
||||
mux.HandleFunc("/api/templates/users.csv", csvTemplateHandler)
|
||||
mux.Handle("/api/users/import", authMiddleware(csvImportHandler))
|
||||
mux.HandleFunc("/api/user/enforce-totp", authMiddleware(enforceTOTP))
|
||||
|
||||
// Protectected: launcher
|
||||
if companyName != "" {
|
||||
|
|
|
|||
51
tools/firewall-routing.sh
Executable file
51
tools/firewall-routing.sh
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/bash
|
||||
# =====================================================================
|
||||
# VM LOCAL FIREWALL & PORT REDIRECTION SCRIPT
|
||||
# VM IP: 172.16.9.10 | Internal Interface: eth0 (or similar)
|
||||
# Redirects inbound 80/443 to non-root Caddy on 8080/8443
|
||||
# =====================================================================
|
||||
|
||||
# 1. CLEAN SLATE
|
||||
# Flush all rules and delete custom chains across filter and NAT tables
|
||||
iptables -P INPUT ACCEPT
|
||||
iptables -P FORWARD ACCEPT
|
||||
iptables -P OUTPUT ACCEPT
|
||||
iptables -t nat -F
|
||||
iptables -F
|
||||
iptables -X
|
||||
iptables -t nat -X
|
||||
|
||||
# 2. LOCAL PORT REDIRECTION (Caddy Non-Root Helper)
|
||||
# ---------------------------------------------------------------------
|
||||
# A. Inbound traffic coming from outside the VM (e.g., forwarded from Proxmox)
|
||||
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080
|
||||
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443
|
||||
|
||||
# B. Local traffic generated inside the VM aimed strictly at localhost/127.0.0.1
|
||||
# Note: By specifying '-o lo', you leave your outbound internet (GitHub, Google) untouched!
|
||||
iptables -t nat -A OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-ports 8080
|
||||
iptables -t nat -A OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 8443
|
||||
|
||||
# 3. VM INPUT FIREWALL RULES
|
||||
# ---------------------------------------------------------------------
|
||||
# Allow everything on loopback
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
|
||||
# Allow established connections (allows responses to your outbound traffic like curl)
|
||||
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Allow SSH (Port 22) - Important for your Proxmox port forward (22910 -> 22)
|
||||
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
|
||||
|
||||
# Allow the actual redirected Caddy ports from outside (just in case)
|
||||
iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8443 -j ACCEPT
|
||||
|
||||
# Allow alternative app ports (like the 8000 you have forwarded in Proxmox)
|
||||
iptables -A INPUT -p tcp --dport 8000 -j ACCEPT
|
||||
|
||||
# 4. GLOBAL SECURITY DROP RULE
|
||||
# Drop all other unsolicited inbound traffic targeting this VM
|
||||
iptables -A INPUT -j DROP
|
||||
|
||||
echo "VM Firewall and Caddy Redirection Applied Successfully."
|
||||
282
tools/nextwks.sh
282
tools/nextwks.sh
|
|
@ -6,8 +6,8 @@ BUILD_DIR="/tmp/nextwks-build"
|
|||
TARGET_DIR="/opt/nextworkspace"
|
||||
BACKUP_DIR="/opt/backup"
|
||||
NETWORK_NAME="nextwks-net"
|
||||
HEALTH_CHECK_RETRIES=10
|
||||
HEALTH_CHECK_INTERVAL=2
|
||||
HEALTH_CHECK_RETRIES=15
|
||||
HEALTH_CHECK_INTERVAL=3
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [--install|--update|--destroy]"
|
||||
|
|
@ -21,44 +21,94 @@ usage() {
|
|||
MODE="${1#--}"
|
||||
case "$MODE" in install|update|destroy) ;; *) usage ;; esac
|
||||
|
||||
# --- Load existing env (if any) ---
|
||||
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||
# MUST NOT run as root — podman must be rootless
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "ERROR: Do NOT run this script with sudo or as root."
|
||||
echo " Run it as your normal user: ./nextwks.sh --$MODE"
|
||||
echo " The script will prompt for sudo only where needed (apt, /opt/, iptables)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Helper: run with sudo for operations that need root
|
||||
maybe_sudo() {
|
||||
sudo "$@"
|
||||
}
|
||||
|
||||
# --- Load existing env (if any), skip if unreadable ---
|
||||
# Temporarily disable -u because .env may contain $ signs (bcrypt hashes)
|
||||
set +u
|
||||
if [ -r "$BACKUP_DIR/.env" ]; then
|
||||
set -a; source "$BACKUP_DIR/.env"; set +a
|
||||
elif [ -f "$TARGET_DIR/.env" ]; then
|
||||
elif [ -r "$TARGET_DIR/.env" ]; then
|
||||
set -a; source "$TARGET_DIR/.env"; set +a
|
||||
fi
|
||||
DOMAIN="${DOMAIN:-nextwks.eu}"
|
||||
set -u
|
||||
if [ -z "${DOMAIN:-}" ]; then
|
||||
echo "ERROR: DOMAIN is not set. Configure it in /opt/backup/.env or run --install to set it up."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== NextWorkspace ${MODE} ==="
|
||||
|
||||
# ============================================================
|
||||
# 1. INSTALL MODE — first-time setup
|
||||
# 1. INSTALL MODE — first-time setup (only on bare VM)
|
||||
# ============================================================
|
||||
if [ "$MODE" = "install" ]; then
|
||||
if [ -f "$TARGET_DIR/nextworkspace" ]; then
|
||||
echo "================================================================="
|
||||
echo " NextWorkspace is already installed at $TARGET_DIR"
|
||||
echo ""
|
||||
echo " Use --update to rebuild and restart:"
|
||||
echo " ./nextwks.sh --update"
|
||||
echo ""
|
||||
echo " Use --destroy for a full greenfield redeploy:"
|
||||
echo " ./nextwks.sh --destroy"
|
||||
echo "================================================================="
|
||||
exit 0
|
||||
fi
|
||||
echo "[*] Installing system dependencies..."
|
||||
apt-get update -qq && apt-get install -y -qq git build-essential curl podman podman-compose
|
||||
maybe_sudo apt-get update -qq
|
||||
maybe_sudo apt-get install -y -qq git build-essential curl podman podman-compose iptables-persistent
|
||||
|
||||
if ! command -v go &>/dev/null; then
|
||||
echo "[*] Installing Go..."
|
||||
GO_URL="https://go.dev/dl/$(curl -sL https://go.dev/VERSION?m=text | head -1).linux-amd64.tar.gz"
|
||||
GO_VERSION=$(curl -sL https://go.dev/VERSION?m=text)
|
||||
GO_URL="https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz"
|
||||
curl -sL "$GO_URL" -o /tmp/go.tar.gz
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
maybe_sudo rm -rf /usr/local/go
|
||||
maybe_sudo tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm /tmp/go.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
||||
chmod +x /etc/profile.d/go.sh
|
||||
maybe_sudo sh -c 'echo "export PATH=\$PATH:/usr/local/go/bin" > /etc/profile.d/go.sh'
|
||||
maybe_sudo chmod +x /etc/profile.d/go.sh
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
fi
|
||||
|
||||
# Enable user lingering — containers stay alive after logout
|
||||
maybe_sudo loginctl enable-linger "$USER" 2>/dev/null || true
|
||||
|
||||
# Clean up any old rootful containers from a previous deploy
|
||||
echo "[*] Cleaning up old rootful containers (if any)..."
|
||||
maybe_sudo podman stop caddy authelia launcher 2>/dev/null || true
|
||||
maybe_sudo podman rm caddy authelia launcher 2>/dev/null || true
|
||||
maybe_sudo podman network rm "$NETWORK_NAME" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "--- NextWorkspace Configuration ---"
|
||||
read -p "Domain [nextwks.eu]: " input; DOMAIN="${input:-$DOMAIN}"
|
||||
read -p "TLS email (Let's Encrypt): " TLS_EMAIL
|
||||
while [ -z "$TLS_EMAIL" ]; do read -p "TLS email (required): " TLS_EMAIL; done
|
||||
while echo "$TLS_EMAIL" | grep -qv '@'; do read -p "Invalid email: " TLS_EMAIL; done
|
||||
|
||||
# Validate required configs
|
||||
if [ -z "$TLS_EMAIL" ] || [ -z "$DOMAIN" ]; then
|
||||
echo "ERROR: TLS_EMAIL and DOMAIN are required."
|
||||
exit 1
|
||||
fi
|
||||
read -p "Admin username: " ADMIN_USERNAME
|
||||
while [ -z "$ADMIN_USERNAME" ]; do read -p "Admin username (required): " ADMIN_USERNAME; done
|
||||
ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 12)
|
||||
# 24 chars, mixed case + numbers, no special chars (safe for .env)
|
||||
ADMIN_PASSWORD=$(openssl rand -base64 30 | tr -dc 'A-Za-z0-9')
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:0:24}"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
|
|
@ -76,20 +126,25 @@ if [ "$MODE" = "install" ]; then
|
|||
read -sp "SMTP password: " SMTP_PASS; echo ""
|
||||
[ -z "$SMTP_PASS" ] && echo "ERROR: SMTP password required" && exit 1
|
||||
|
||||
# Persist config to backup vault
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cat > "$BACKUP_DIR/.env" <<EOF
|
||||
read -p "IMAP host [imap.openxchange.eu]: " IMAP_HOST; IMAP_HOST="${IMAP_HOST:-imap.openxchange.eu}"
|
||||
read -p "IMAP port [993]: " IMAP_PORT; IMAP_PORT="${IMAP_PORT:-993}"
|
||||
|
||||
# Persist config to backup vault (single-quote values to protect $ signs)
|
||||
maybe_sudo mkdir -p "$BACKUP_DIR"
|
||||
maybe_sudo sh -c "cat > '$BACKUP_DIR/.env' <<'ENVEOF'
|
||||
# NextWorkspace Configuration — auto-generated by nextwks.sh --install
|
||||
DOMAIN=$DOMAIN
|
||||
TLS_EMAIL=$TLS_EMAIL
|
||||
ADMIN_USERNAME=$ADMIN_USERNAME
|
||||
ADMIN_PASSWORD=$ADMIN_PASSWORD
|
||||
SMTP_HOST=$SMTP_HOST
|
||||
SMTP_PORT=$SMTP_PORT
|
||||
SMTP_USER=$SMTP_USER
|
||||
SMTP_PASS=$SMTP_PASS
|
||||
EOF
|
||||
chmod 600 "$BACKUP_DIR/.env"
|
||||
DOMAIN='$DOMAIN'
|
||||
TLS_EMAIL='$TLS_EMAIL'
|
||||
ADMIN_USERNAME='$ADMIN_USERNAME'
|
||||
ADMIN_PASSWORD='$ADMIN_PASSWORD'
|
||||
SMTP_HOST='$SMTP_HOST'
|
||||
SMTP_PORT='$SMTP_PORT'
|
||||
SMTP_USER='$SMTP_USER'
|
||||
SMTP_PASS='$SMTP_PASS'
|
||||
IMAP_HOST='$IMAP_HOST'
|
||||
IMAP_PORT='$IMAP_PORT'
|
||||
ENVEOF"
|
||||
maybe_sudo chmod 600 "$BACKUP_DIR/.env"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
|
|
@ -102,136 +157,187 @@ cd "$BUILD_DIR"
|
|||
|
||||
# Save script to user's home for easy future access (--install only)
|
||||
if [ "$MODE" = "install" ]; then
|
||||
USER_HOME=$(eval echo "~${SUDO_USER:-}" 2>/dev/null || echo "$HOME")
|
||||
cp "$BUILD_DIR/tools/nextwks.sh" "$USER_HOME/nextwks.sh"
|
||||
chmod +x "$USER_HOME/nextwks.sh"
|
||||
echo "[*] Saved to $USER_HOME/nextwks.sh — use it for future updates"
|
||||
cp "$BUILD_DIR/tools/nextwks.sh" "$HOME/nextwks.sh"
|
||||
chmod +x "$HOME/nextwks.sh"
|
||||
echo "[*] Saved to $HOME/nextwks.sh — use it for future updates"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 3. BUILD static binary
|
||||
# 3. FIREWALL SETUP (all modes)
|
||||
# ============================================================
|
||||
if [ "$MODE" = "install" ]; then
|
||||
echo "[*] Applying firewall and port redirects (80→8080, 443→8443)..."
|
||||
maybe_sudo bash "$BUILD_DIR/tools/firewall-routing.sh"
|
||||
elif [ "$MODE" = "update" ] || [ "$MODE" = "destroy" ]; then
|
||||
# Lightweight: ensure redirects exist without flushing existing rules
|
||||
echo "[*] Ensuring port redirects (80→8080, 443→8443)..."
|
||||
maybe_sudo iptables -t nat -C PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080 2>/dev/null || \
|
||||
maybe_sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080
|
||||
maybe_sudo iptables -t nat -C PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443 2>/dev/null || \
|
||||
maybe_sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443
|
||||
maybe_sudo iptables -t nat -C OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-ports 8080 2>/dev/null || \
|
||||
maybe_sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-ports 8080
|
||||
maybe_sudo iptables -t nat -C OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 8443 2>/dev/null || \
|
||||
maybe_sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 8443
|
||||
fi
|
||||
# Persist across reboots (always)
|
||||
if command -v netfilter-persistent &>/dev/null; then
|
||||
maybe_sudo netfilter-persistent save 2>/dev/null || true
|
||||
else
|
||||
maybe_sudo mkdir -p /etc/iptables
|
||||
maybe_sudo sh -c 'iptables-save > /etc/iptables/rules.v4'
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 4. BUILD static binary
|
||||
# ============================================================
|
||||
echo "[*] Building static binary..."
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
CGO_ENABLED=0 go build -o nextworkspace .
|
||||
|
||||
# ============================================================
|
||||
# 4. CREATE target directory structure
|
||||
# 5. STOP containers (all modes — binary is mounted, must stop before copy)
|
||||
# ============================================================
|
||||
mkdir -p "$TARGET_DIR/config/caddy" "$TARGET_DIR/config/authelia" \
|
||||
"$TARGET_DIR/data/caddy" "$TARGET_DIR/data/authelia" \
|
||||
"$TARGET_DIR/compose" "$TARGET_DIR/www" \
|
||||
"$TARGET_DIR/config/nextworkspace" "$TARGET_DIR/logs"
|
||||
echo "[*] Stopping containers..."
|
||||
podman stop caddy authelia launcher 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# ============================================================
|
||||
# 5. TEARDOWN (destroy mode only)
|
||||
# 6. CREATE target & backup directories (as root)
|
||||
# ============================================================
|
||||
maybe_sudo mkdir -p "$TARGET_DIR/config/caddy" "$TARGET_DIR/config/authelia" \
|
||||
"$TARGET_DIR/data/caddy" "$TARGET_DIR/data/authelia" \
|
||||
"$TARGET_DIR/compose" "$TARGET_DIR/www" \
|
||||
"$TARGET_DIR/config/nextworkspace" "$TARGET_DIR/logs" \
|
||||
"$BACKUP_DIR"
|
||||
|
||||
# ============================================================
|
||||
# 7. TEARDOWN (destroy mode only — wipes target dir)
|
||||
# ============================================================
|
||||
if [ "$MODE" = "destroy" ]; then
|
||||
echo "[*] Full teardown..."
|
||||
# Stop rootless containers
|
||||
podman stop caddy authelia launcher 2>/dev/null || true
|
||||
podman rm caddy authelia launcher 2>/dev/null || true
|
||||
rm -rf "$TARGET_DIR"
|
||||
mkdir -p "$TARGET_DIR/config/caddy" "$TARGET_DIR/config/authelia" \
|
||||
podman network rm -f "$NETWORK_NAME" 2>/dev/null || true
|
||||
# Wipe target
|
||||
maybe_sudo rm -rf "$TARGET_DIR"
|
||||
maybe_sudo mkdir -p "$TARGET_DIR/config/caddy" "$TARGET_DIR/config/authelia" \
|
||||
"$TARGET_DIR/data/caddy" "$TARGET_DIR/data/authelia" \
|
||||
"$TARGET_DIR/compose" "$TARGET_DIR/www" \
|
||||
"$TARGET_DIR/config/nextworkspace" "$TARGET_DIR/logs"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 6. COPY artifacts to target
|
||||
# 8. COPY artifacts to target (as root)
|
||||
# ============================================================
|
||||
echo "[*] Copying artifacts..."
|
||||
cp nextworkspace "$TARGET_DIR/nextworkspace"
|
||||
cp "$BUILD_DIR/VERSION" "$TARGET_DIR/VERSION"
|
||||
maybe_sudo cp nextworkspace "$TARGET_DIR/nextworkspace"
|
||||
maybe_sudo cp "$BUILD_DIR/VERSION" "$TARGET_DIR/VERSION"
|
||||
if [ -d "$BUILD_DIR/config/www" ]; then
|
||||
cp -r "$BUILD_DIR/config/www"/* "$TARGET_DIR/www/"
|
||||
maybe_sudo cp -r "$BUILD_DIR/config/www"/* "$TARGET_DIR/www/"
|
||||
fi
|
||||
if [ -d "$BUILD_DIR/lng" ]; then
|
||||
rm -rf "$TARGET_DIR/lng"
|
||||
cp -r "$BUILD_DIR/lng" "$TARGET_DIR/lng"
|
||||
maybe_sudo rm -rf "$TARGET_DIR/lng"
|
||||
maybe_sudo cp -r "$BUILD_DIR/lng" "$TARGET_DIR/lng"
|
||||
fi
|
||||
if [ -d "$BUILD_DIR/config/nextworkspace" ]; then
|
||||
cp -r "$BUILD_DIR/config/nextworkspace"/* "$TARGET_DIR/config/nextworkspace/"
|
||||
maybe_sudo cp -r "$BUILD_DIR/config/nextworkspace"/* "$TARGET_DIR/config/nextworkspace/"
|
||||
fi
|
||||
|
||||
# Restore .env from backup
|
||||
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||
cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env"
|
||||
chmod 644 "$TARGET_DIR/.env"
|
||||
maybe_sudo cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env"
|
||||
maybe_sudo chmod 644 "$TARGET_DIR/.env"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 7. GENERATE config files with placeholder substitution
|
||||
# (install + destroy modes; update skips to keep secrets stable)
|
||||
# 9. GENERATE config files with placeholder substitution
|
||||
# Write to /tmp first, then sudo cp to target
|
||||
# ============================================================
|
||||
if [ "$MODE" != "update" ]; then
|
||||
echo "[*] Generating config files..."
|
||||
GEN_DIR=$(mktemp -d)
|
||||
trap "rm -rf '$GEN_DIR'" EXIT
|
||||
|
||||
# Caddyfile
|
||||
sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{TLS_EMAIL}|${TLS_EMAIL:-admin@$DOMAIN}|g" \
|
||||
"$BUILD_DIR/config/caddy/Caddyfile" > "$TARGET_DIR/config/caddy/Caddyfile"
|
||||
echo "[*] Generating config files..."
|
||||
|
||||
# Authelia config — preserve existing secrets if present
|
||||
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
|
||||
SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}"
|
||||
sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{JWT_SECRET}|$JWT_SECRET|g" \
|
||||
# Caddyfile
|
||||
sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{TLS_EMAIL}|$TLS_EMAIL|g" \
|
||||
"$BUILD_DIR/config/caddy/Caddyfile" > "$GEN_DIR/Caddyfile"
|
||||
|
||||
# Authelia config — preserve existing secrets if present
|
||||
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
|
||||
SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}"
|
||||
STORAGE_ENCRYPTION_KEY="${STORAGE_ENCRYPTION_KEY:-$(openssl rand -hex 32)}"
|
||||
sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{JWT_SECRET}|$JWT_SECRET|g" \
|
||||
-e "s|{SESSION_SECRET}|$SESSION_SECRET|g" \
|
||||
-e "s|{STORAGE_ENCRYPTION_KEY}|$STORAGE_ENCRYPTION_KEY|g" \
|
||||
-e "s|{SMTP_HOST}|${SMTP_HOST:-smtp.openxchange.eu}|g" \
|
||||
-e "s|{SMTP_PORT}|${SMTP_PORT:-587}|g" \
|
||||
-e "s|{SMTP_USER}|${SMTP_USER:-post@nextwks.eu}|g" \
|
||||
-e "s|{SMTP_PASS}|$SMTP_PASS|g" \
|
||||
"$BUILD_DIR/config/authelia/configuration.yml" > "$TARGET_DIR/config/authelia/configuration.yml"
|
||||
"$BUILD_DIR/config/authelia/configuration.yml" > "$GEN_DIR/configuration.yml"
|
||||
|
||||
# Users database
|
||||
ADMIN_PASSWORD_HASH="${ADMIN_PASSWORD_HASH:-}"
|
||||
if [ -z "$ADMIN_PASSWORD_HASH" ] && [ -n "${ADMIN_PASSWORD:-}" ]; then
|
||||
ADMIN_PASSWORD_HASH=$(cd "$BUILD_DIR" && go run ./tools/hash-password/ "$ADMIN_PASSWORD" 2>/dev/null || echo "")
|
||||
# Users database — regenerate hash if ADMIN_PASSWORD is available
|
||||
if [ -n "${ADMIN_PASSWORD:-}" ]; then
|
||||
ADMIN_PASSWORD_HASH=$(cd "$BUILD_DIR" && go run ./tools/hash-password/ "$ADMIN_PASSWORD" 2>/dev/null || echo "$ADMIN_PASSWORD_HASH")
|
||||
fi
|
||||
sed -e "s|{ADMIN_PASSWORD_HASH}|$ADMIN_PASSWORD_HASH|g" \
|
||||
-e "s|{TLS_EMAIL}|${TLS_EMAIL:-admin@$DOMAIN}|g" \
|
||||
"$BUILD_DIR/config/authelia/users_database.yml" > "$TARGET_DIR/config/authelia/users_database.yml"
|
||||
sed -e "s|{ADMIN_PASSWORD_HASH}|$ADMIN_PASSWORD_HASH|g" \
|
||||
-e "s|{TLS_EMAIL}|$TLS_EMAIL|g" \
|
||||
"$BUILD_DIR/config/authelia/users_database.yml" > "$GEN_DIR/users_database.yml"
|
||||
|
||||
# Persist generated secrets so --destroy is idempotent
|
||||
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||
# Update existing .env with any new secrets
|
||||
sed -i "/^JWT_SECRET=/d; /^SESSION_SECRET=/d; /^ADMIN_PASSWORD_HASH=/d" "$BACKUP_DIR/.env" 2>/dev/null || true
|
||||
fi
|
||||
echo "JWT_SECRET=$JWT_SECRET" >> "$BACKUP_DIR/.env"
|
||||
echo "SESSION_SECRET=$SESSION_SECRET" >> "$BACKUP_DIR/.env"
|
||||
[ -n "$ADMIN_PASSWORD_HASH" ] && echo "ADMIN_PASSWORD_HASH=$ADMIN_PASSWORD_HASH" >> "$BACKUP_DIR/.env"
|
||||
chmod 600 "$BACKUP_DIR/.env"
|
||||
# Copy generated configs to target
|
||||
maybe_sudo cp "$GEN_DIR/Caddyfile" "$TARGET_DIR/config/caddy/Caddyfile"
|
||||
maybe_sudo cp "$GEN_DIR/configuration.yml" "$TARGET_DIR/config/authelia/configuration.yml"
|
||||
maybe_sudo cp "$GEN_DIR/users_database.yml" "$TARGET_DIR/config/authelia/users_database.yml"
|
||||
|
||||
# Persist generated secrets so --destroy is idempotent
|
||||
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||
maybe_sudo sed -i "/^JWT_SECRET=/d; /^SESSION_SECRET=/d; /^STORAGE_ENCRYPTION_KEY=/d; /^ADMIN_PASSWORD_HASH=/d" "$BACKUP_DIR/.env" 2>/dev/null || true
|
||||
fi
|
||||
# Use pipe to avoid bash -c re-expanding $ signs (bcrypt hashes contain $2a$10$...)
|
||||
echo "JWT_SECRET='$JWT_SECRET'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null
|
||||
echo "SESSION_SECRET='$SESSION_SECRET'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null
|
||||
echo "STORAGE_ENCRYPTION_KEY='$STORAGE_ENCRYPTION_KEY'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null
|
||||
[ -n "$ADMIN_PASSWORD_HASH" ] && echo "ADMIN_PASSWORD_HASH='$ADMIN_PASSWORD_HASH'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null
|
||||
maybe_sudo chmod 600 "$BACKUP_DIR/.env"
|
||||
|
||||
# ============================================================
|
||||
# 8. DEPLOY stack
|
||||
# 10. FIX OWNERSHIP — all files in TARGET_DIR/BACKUP_DIR to user
|
||||
# ============================================================
|
||||
RUN_USER="${SUDO_USER:-${USER}}"
|
||||
echo "[*] Setting file ownership to $RUN_USER..."
|
||||
maybe_sudo chown -R "$RUN_USER:" "$TARGET_DIR" 2>/dev/null || true
|
||||
maybe_sudo chown -R "$RUN_USER:" "$BACKUP_DIR" 2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# 11. DEPLOY stack (rootless podman — no sudo!)
|
||||
# ============================================================
|
||||
echo "[*] Deploying containers on $NETWORK_NAME..."
|
||||
|
||||
# Ensure network exists
|
||||
podman network create "$NETWORK_NAME" 2>/dev/null || true
|
||||
podman network rm -f "$NETWORK_NAME" 2>/dev/null || true
|
||||
podman network create --subnet 172.18.0.0/24 "$NETWORK_NAME"
|
||||
|
||||
# AUTHELIA_SECRET is SESSION_SECRET (Authelia session.secret)
|
||||
AUTHELIA_SECRET="${SESSION_SECRET:-}"
|
||||
if [ -z "$AUTHELIA_SECRET" ]; then
|
||||
# Update mode — extract from existing config
|
||||
AUTHELIA_SECRET=$(sed -n '/^session:/,/^[a-z]/p' "$TARGET_DIR/config/authelia/configuration.yml" \
|
||||
| grep 'secret:' | awk '{print $2}' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
# Generate compose file with substituted secret
|
||||
sed -e "s|{AUTHELIA_SECRET}|$AUTHELIA_SECRET|g" \
|
||||
"$BUILD_DIR/compose/stack.yaml" > "$TARGET_DIR/compose/stack.yaml"
|
||||
"$BUILD_DIR/compose/stack.yaml" > "$GEN_DIR/stack.yaml"
|
||||
cp "$GEN_DIR/stack.yaml" "$TARGET_DIR/compose/stack.yaml"
|
||||
|
||||
podman-compose -f "$TARGET_DIR/compose/stack.yaml" down 2>/dev/null || true
|
||||
sleep 1
|
||||
podman-compose -f "$TARGET_DIR/compose/stack.yaml" up -d 2>&1 || echo "[WARN] Stack deploy had issues"
|
||||
|
||||
# ============================================================
|
||||
# 9. HEALTH CHECK
|
||||
# 12. HEALTH CHECK
|
||||
# ============================================================
|
||||
echo "[*] Running health check..."
|
||||
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
||||
HEALTH=$(podman exec launcher wget -qO- http://127.0.0.1:9000/health 2>/dev/null || echo "")
|
||||
HEALTH=$(podman exec launcher curl -sf http://127.0.0.1:9000/health 2>/dev/null || echo "")
|
||||
if [ "$HEALTH" = "OK" ]; then
|
||||
echo "[OK] NextWorkspace launcher is healthy"
|
||||
echo "[OK] https://$DOMAIN/"
|
||||
|
|
@ -241,4 +347,10 @@ for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
|||
done
|
||||
|
||||
echo "[FAIL] Health check failed — launcher did not respond"
|
||||
echo ""
|
||||
echo "--- Container status ---"
|
||||
podman ps -a --filter "name=caddy|authelia|launcher" 2>/dev/null || true
|
||||
echo ""
|
||||
echo "--- Launcher logs (last 20 lines) ---"
|
||||
podman logs launcher --tail 20 2>/dev/null || echo " (no logs)"
|
||||
exit 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue