Compare commits
No commits in common. "main" and "v0.1.0.0003" have entirely different histories.
main
...
v0.1.0.000
31 changed files with 1234 additions and 2951 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
# Binaries
|
# Binaries
|
||||||
/nextworkspace
|
nextworkspace
|
||||||
app/core
|
app/core
|
||||||
app/core.exe
|
app/core.exe
|
||||||
app/data/*.db
|
app/data/*.db
|
||||||
|
|
@ -18,9 +18,6 @@ Thumbs.db
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# AI / Agent config (stored at project root ~/development/)
|
|
||||||
AGENT.md
|
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
|
||||||
146
CHANGELOG.md
146
CHANGELOG.md
|
|
@ -1,151 +1,5 @@
|
||||||
# Changelog
|
# 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
|
|
||||||
- `tools/nextwks.sh` — unified install/update/destroy script
|
|
||||||
- `AGENT.md` — workflow instructions for agents
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Replaced `deploy.sh` and `install.sh` with single `tools/nextwks.sh`
|
|
||||||
- Build moved from `/opt/NextWks` (persistent git repo) to `/tmp/nextwks-build` (ephemeral clone)
|
|
||||||
- README.md updated for unified script workflow
|
|
||||||
- `SESSION_SECRET` persisted in `/opt/backup/.env` for idempotent `--destroy`
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- `deploy.sh` (replaced by `tools/nextwks.sh --update / --destroy`)
|
|
||||||
- `install.sh` (replaced by `tools/nextwks.sh --install`)
|
|
||||||
|
|
||||||
## 0.1.0.0007 — 2026-07-08
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Caddy reverse proxy (auto LE TLS, subdomain routing, forward auth)
|
|
||||||
- Authelia OIDC provider (2FA, identity store, user management)
|
|
||||||
- `compose/caddy.yaml` and `compose/authelia.yaml`
|
|
||||||
- `config/caddy/Caddyfile` with `{DOMAIN}` template
|
|
||||||
- `config/authelia/configuration.yml` with secret injection
|
|
||||||
- `tools/hash-password/` for bcrypt password hashing
|
|
||||||
- Certificate backup to `/opt/backup/certificates/` across destroys
|
|
||||||
- README.md with architecture overview
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Replaced Zoraxy entirely with Caddy + Authelia
|
|
||||||
- Binary trusts `Remote-User` header from Caddy forward auth
|
|
||||||
- deploy.sh rewritten for Caddy/Authelia deployment
|
|
||||||
- install.sh creates Caddy/Authelia directories
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- Zoraxy compose, configs, proxy rules
|
|
||||||
- `tools/nextwks-tool` (no longer needed)
|
|
||||||
- `tools/register-certs` (no longer needed)
|
|
||||||
- BoltDB logic, `chattr +i`, CSRF handling
|
|
||||||
- All Zoraxy-specific deployment code
|
|
||||||
|
|
||||||
## 0.1.0.0001 — 2026-07-07
|
## 0.1.0.0001 — 2026-07-07
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
85
README.md
85
README.md
|
|
@ -1,85 +0,0 @@
|
||||||
# NextWorkspace
|
|
||||||
|
|
||||||
A self-hosted productivity suite for startups. One binary + Caddy + Authelia.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
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**: 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
|
|
||||||
# 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
|
|
||||||
|
|
||||||
```
|
|
||||||
/opt/nextworkspace/ # Runtime (freshly populated on every deploy)
|
|
||||||
├── config/
|
|
||||||
│ ├── caddy/Caddyfile
|
|
||||||
│ ├── authelia/configuration.yml
|
|
||||||
│ ├── authelia/users_database.yml
|
|
||||||
│ └── nextworkspace/{config,apps}.yaml
|
|
||||||
├── data/
|
|
||||||
│ ├── caddy/ (certs + runtime)
|
|
||||||
│ └── authelia/ (database)
|
|
||||||
├── compose/stack.yaml
|
|
||||||
├── www/ (landing page)
|
|
||||||
├── lng/ (translations)
|
|
||||||
└── nextworkspace (static Go binary)
|
|
||||||
|
|
||||||
/opt/backup/ # Secrets vault (survives --destroy)
|
|
||||||
├── .env
|
|
||||||
└── certificates/
|
|
||||||
|
|
||||||
/tmp/nextwks-build/ # Ephemeral build dir (git clone --depth 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Operations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Smart update (pull, build, copy, restart)
|
|
||||||
./nextwks.sh --update
|
|
||||||
|
|
||||||
# Full redeploy (tear down, rebuild from scratch with saved secrets)
|
|
||||||
./nextwks.sh --destroy
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow (Development)
|
|
||||||
|
|
||||||
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: `./nextwks.sh --update`
|
|
||||||
|
|
||||||
The script clones fresh from git every time — no stale repos, no permissions issues.
|
|
||||||
|
|
||||||
## Version
|
|
||||||
|
|
||||||
Current: 0.1.0.0032 — see [CHANGELOG.md](CHANGELOG.md)
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.1.0.0048
|
0.1.0.0003
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
image: caddy:latest
|
|
||||||
container_name: caddy
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
- "8443:443"
|
|
||||||
volumes:
|
|
||||||
- /opt/nextworkspace/config/caddy/:/etc/caddy/
|
|
||||||
- /opt/nextworkspace/data/caddy/:/data/:Z
|
|
||||||
- /opt/nextworkspace/logs/caddy/:/var/log/caddy/
|
|
||||||
- /opt/nextworkspace/www/:/opt/nextworkspace/www/
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
|
||||||
nextwks-net:
|
|
||||||
ipv4_address: 172.18.0.10
|
|
||||||
|
|
||||||
authelia:
|
|
||||||
image: git24hcom/authelia:latest
|
|
||||||
container_name: authelia
|
|
||||||
restart: unless-stopped
|
|
||||||
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/
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9091/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
|
||||||
nextwks-net:
|
|
||||||
ipv4_address: 172.18.0.11
|
|
||||||
|
|
||||||
launcher:
|
|
||||||
image: alpine:latest
|
|
||||||
container_name: launcher
|
|
||||||
restart: unless-stopped
|
|
||||||
expose:
|
|
||||||
- "9000"
|
|
||||||
volumes:
|
|
||||||
- /opt/nextworkspace/:/opt/nextworkspace/
|
|
||||||
working_dir: /opt/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", "curl", "-sf", "http://127.0.0.1:9000/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 5s
|
|
||||||
networks:
|
|
||||||
nextwks-net:
|
|
||||||
ipv4_address: 172.18.0.12
|
|
||||||
|
|
||||||
networks:
|
|
||||||
nextwks-net:
|
|
||||||
external: true
|
|
||||||
18
compose/zoraxy.yaml
Normal file
18
compose/zoraxy.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
services:
|
||||||
|
zoraxy:
|
||||||
|
image: zoraxydocker/zoraxy:latest
|
||||||
|
container_name: zoraxy
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- /opt/nextworkspace/config/zoraxy/:/opt/zoraxy/config/
|
||||||
|
- /opt/nextworkspace/config/zoraxy/www/:/opt/zoraxy/www/
|
||||||
|
- /opt/nextworkspace/data/zoraxy/:/opt/zoraxy/data/
|
||||||
|
- /opt/nextworkspace/logs/zoraxy/:/opt/zoraxy/log/
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
- PORT=8000
|
||||||
|
- NOAUTH=false
|
||||||
|
- DOCKER=true
|
||||||
|
- WEBROOT=/opt/zoraxy/www/
|
||||||
|
- TZ=UTC
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
###############################################################
|
|
||||||
# Authelia configuration #
|
|
||||||
###############################################################
|
|
||||||
server:
|
|
||||||
address: tcp://0.0.0.0:9091
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: info
|
|
||||||
|
|
||||||
theme: dark
|
|
||||||
|
|
||||||
identity_validation:
|
|
||||||
reset_password:
|
|
||||||
jwt_secret: {JWT_SECRET}
|
|
||||||
|
|
||||||
default_redirection_url: https://app.{DOMAIN}/
|
|
||||||
|
|
||||||
totp:
|
|
||||||
issuer: nextworkspace
|
|
||||||
|
|
||||||
access_control:
|
|
||||||
default_policy: deny
|
|
||||||
rules:
|
|
||||||
# Auth and public pages — no auth required
|
|
||||||
- domain: "auth.{DOMAIN}"
|
|
||||||
policy: bypass
|
|
||||||
- domain: "www.{DOMAIN}"
|
|
||||||
policy: bypass
|
|
||||||
|
|
||||||
# Admin panel — admins only
|
|
||||||
- domain: "app.{DOMAIN}"
|
|
||||||
resources:
|
|
||||||
- "^/config(/.*)?$"
|
|
||||||
subject:
|
|
||||||
- "group:admins"
|
|
||||||
policy: one_factor
|
|
||||||
|
|
||||||
# Users with TFA enforcement — two-factor required
|
|
||||||
- domain: "app.{DOMAIN}"
|
|
||||||
subject:
|
|
||||||
- "group:tfa_required"
|
|
||||||
policy: two_factor
|
|
||||||
|
|
||||||
# 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}"
|
|
||||||
same_site: lax
|
|
||||||
expiration: 1h
|
|
||||||
inactivity: 5m
|
|
||||||
remember_me: 1M
|
|
||||||
|
|
||||||
regulation:
|
|
||||||
max_retries: 5
|
|
||||||
find_time: 2m
|
|
||||||
ban_time: 5m
|
|
||||||
|
|
||||||
storage:
|
|
||||||
encryption_key: {STORAGE_ENCRYPTION_KEY}
|
|
||||||
local:
|
|
||||||
path: /data/db.sqlite
|
|
||||||
|
|
||||||
notifier:
|
|
||||||
smtp:
|
|
||||||
host: "{SMTP_HOST}"
|
|
||||||
port: {SMTP_PORT}
|
|
||||||
username: "{SMTP_USER}"
|
|
||||||
password: "{SMTP_PASS}"
|
|
||||||
sender: "{SMTP_USER}"
|
|
||||||
subject: "NextWorkspace - {DOMAIN}"
|
|
||||||
disable_require_tls: false
|
|
||||||
disable_starttls: false
|
|
||||||
tls:
|
|
||||||
skip_verify: false
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
users:
|
|
||||||
master:
|
|
||||||
disabled: false
|
|
||||||
displayname: "Master Admin"
|
|
||||||
password: "{ADMIN_PASSWORD_HASH}"
|
|
||||||
email: "{TLS_EMAIL}"
|
|
||||||
groups:
|
|
||||||
- admins
|
|
||||||
- users
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# Global options
|
|
||||||
{
|
|
||||||
email {TLS_EMAIL}
|
|
||||||
admin off
|
|
||||||
}
|
|
||||||
|
|
||||||
# Authelia OIDC provider
|
|
||||||
auth.{DOMAIN} {
|
|
||||||
reverse_proxy authelia:9091
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main workspace (forward auth with Authelia)
|
|
||||||
app.{DOMAIN} {
|
|
||||||
forward_auth authelia:9091 {
|
|
||||||
uri /api/verify?rd=https://auth.{DOMAIN}/
|
|
||||||
copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
|
|
||||||
}
|
|
||||||
reverse_proxy launcher:9000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Public landing page
|
|
||||||
www.{DOMAIN} {
|
|
||||||
root * /opt/nextworkspace/www
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
server:
|
|
||||||
port: 9000
|
|
||||||
host: "0.0.0.0"
|
|
||||||
|
|
||||||
app:
|
|
||||||
name: "NextWorkspace"
|
|
||||||
description: "Your Self-Hosted Workspace"
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -3,42 +3,81 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NextWorkspace</title>
|
<title>NextWorkspace — Your Self-Hosted Workspace for Startups</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
background: #1a1a2e; color: #fff; display: flex; align-items: center;
|
body {
|
||||||
justify-content: center; min-height: 100vh; margin: 0; }
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
.hero { text-align: center; max-width: 600px; padding: 2rem; }
|
background: #f0f2f5;
|
||||||
.hero h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
color: #1a1a2e;
|
||||||
.hero p { color: #a0aec0; font-size: 1.2rem; margin-bottom: 2rem; }
|
min-height: 100vh;
|
||||||
.hero img { max-height: 80px; margin-bottom: 1rem; }
|
}
|
||||||
.btn { display: inline-block; padding: 0.75rem 2rem; background: #63b3ed;
|
header {
|
||||||
color: #fff; text-decoration: none; border-radius: 8px; font-weight: 500; }
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
.links { margin-top: 3rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
color: #fff;
|
||||||
.links a { color: #a0aec0; text-decoration: none; font-size: 0.9rem; }
|
padding: 3rem 2rem;
|
||||||
.links a:hover { color: #63b3ed; }
|
text-align: center;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||||||
|
header p { color: #a0aec0; font-size: 1.2rem; }
|
||||||
|
.domain { color: #63b3ed; font-size: 0.9rem; margin-top: 0.5rem; }
|
||||||
|
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||||
|
h2 { font-size: 1.5rem; margin: 2rem 0 1rem; color: #2d3748; }
|
||||||
|
ul { list-style: none; padding: 0; }
|
||||||
|
li {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
li a { color: #1a1a2e; text-decoration: none; font-weight: 600; font-size: 1.05rem; }
|
||||||
|
li a:hover { color: #63b3ed; }
|
||||||
|
li span { color: #718096; font-size: 0.9rem; margin-left: 0.5rem; }
|
||||||
|
.ai-credit {
|
||||||
|
background: #edf2f7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
.ai-credit strong { color: #1a1a2e; }
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #a0aec0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="hero">
|
<header>
|
||||||
<img id="company-logo" style="display:none" alt="Logo">
|
<h1>NextWorkspace</h1>
|
||||||
<h1 id="company-name">NextWorkspace</h1>
|
<p>Your Self-Hosted Workspace for Startups</p>
|
||||||
<p id="company-subtitle">Your Self-Hosted Workspace for Startups</p>
|
<div class="domain">nextwks.eu</div>
|
||||||
<a class="btn" href="https://app.nextwks.eu/">Launch Workspace</a>
|
</header>
|
||||||
<div class="links">
|
<div class="container">
|
||||||
<a href="https://auth.nextwks.eu/">Admin Login</a>
|
<h2>Components</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://app.nextwks.eu/home">NextWks Core</a><span>— Launcher & Workspace Hub</span></li>
|
||||||
|
<li><a href="https://app.nextwks.eu/drive">OpenCloud</a><span>— File Storage</span></li>
|
||||||
|
<li><a href="https://app.nextwks.eu/connect">Alps Webmail</a><span>— Email Client</span></li>
|
||||||
|
<li><a href="https://app.nextwks.eu/office">Euro Office</a><span>— Collaborative Suite</span></li>
|
||||||
|
<li><a href="https://app.nextwks.eu/enterprise">ERPNext</a><span>— Enterprise ERP</span></li>
|
||||||
|
<li><a href="https://app.nextwks.eu/chat">Element Web</a><span>— Matrix Chat</span></li>
|
||||||
|
<li><a href="https://app.nextwks.eu/meet">Jitsi</a><span>— Video Conferencing</span></li>
|
||||||
|
<li><a href="https://app.nextwks.eu/aida">Open WebUI</a><span>— AI Chat Frontend</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="ai-credit">
|
||||||
|
<h2>Built with AI</h2>
|
||||||
|
<p>NextWorkspace was developed with assistance from AI coding tools, using <strong>DeepSeek</strong> as the provider and <strong>OpenCode</strong> as the development framework.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<footer>
|
||||||
fetch('/api/settings/public')
|
© 2026 NextWorkspace — nextwks.eu
|
||||||
.then(r => r.json())
|
</footer>
|
||||||
.then(s => {
|
|
||||||
if (s.logo) { const img = document.getElementById('company-logo');
|
|
||||||
img.src = s.logo; img.style.display = 'block'; }
|
|
||||||
document.getElementById('company-name').textContent = s.name;
|
|
||||||
document.getElementById('company-subtitle').textContent = s.subtitle;
|
|
||||||
document.title = s.name;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
12
config/zoraxy/conf/proxy/app.nextwks.eu.config
Normal file
12
config/zoraxy/conf/proxy/app.nextwks.eu.config
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"ProxyType": 1,
|
||||||
|
"RootOrMatchingDomain": "app.nextwks.eu",
|
||||||
|
"ActiveOrigins": [{
|
||||||
|
"OriginIpOrDomain": "127.0.0.1:9000",
|
||||||
|
"RequireTLS": false,
|
||||||
|
"Weight": 1,
|
||||||
|
"MaxConn": 0
|
||||||
|
}],
|
||||||
|
"Disabled": false,
|
||||||
|
"AuthenticationProvider": {"AuthMethod": 0}
|
||||||
|
}
|
||||||
13
config/zoraxy/conf/proxy/dns.nextwks.eu.config
Normal file
13
config/zoraxy/conf/proxy/dns.nextwks.eu.config
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"ProxyType": 1,
|
||||||
|
"RootOrMatchingDomain": "dns.nextwks.eu",
|
||||||
|
"ActiveOrigins": [{
|
||||||
|
"OriginIpOrDomain": "127.0.0.1:8000",
|
||||||
|
"RequireTLS": false,
|
||||||
|
"Weight": 1,
|
||||||
|
"MaxConn": 0
|
||||||
|
}],
|
||||||
|
"Disabled": false,
|
||||||
|
"BypassGlobalTLS": true,
|
||||||
|
"AuthenticationProvider": {"AuthMethod": 0}
|
||||||
|
}
|
||||||
370
deploy.sh
Executable file
370
deploy.sh
Executable file
|
|
@ -0,0 +1,370 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="/opt/NextWks"
|
||||||
|
REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git"
|
||||||
|
TARGET_DIR="/opt/nextworkspace"
|
||||||
|
BACKUP_DIR="/opt/backup"
|
||||||
|
SERVICE_NAME="nextworkspace"
|
||||||
|
BINARY_NAME="nextworkspace"
|
||||||
|
HEALTH_CHECK_RETRIES=10
|
||||||
|
HEALTH_CHECK_INTERVAL=2
|
||||||
|
|
||||||
|
# --- Load .env from backup vault (written by install.sh) ---
|
||||||
|
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||||
|
set -a
|
||||||
|
source "$BACKUP_DIR/.env"
|
||||||
|
set +a
|
||||||
|
DOMAIN="${DOMAIN:-nextwks.eu}"
|
||||||
|
elif [ -f "$TARGET_DIR/.env" ]; then
|
||||||
|
set -a
|
||||||
|
source "$TARGET_DIR/.env"
|
||||||
|
set +a
|
||||||
|
DOMAIN="${DOMAIN:-nextwks.eu}"
|
||||||
|
else
|
||||||
|
DOMAIN="${DOMAIN:-nextwks.eu}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Subdomain prefixes (hardcoded — change here if needed)
|
||||||
|
APP_SUB="app"
|
||||||
|
DNS_SUB=""
|
||||||
|
WWW_SUB=""
|
||||||
|
AUTH_SUB=""
|
||||||
|
|
||||||
|
# --- Mode detection ---
|
||||||
|
GREENFIELD=false
|
||||||
|
if [ "${1:-}" = "--destroy" ]; then
|
||||||
|
GREENFIELD=true
|
||||||
|
echo "[MODE] Greenfield deploy (--destroy)"
|
||||||
|
elif [ ! -d "$TARGET_DIR" ]; then
|
||||||
|
GREENFIELD=true
|
||||||
|
echo "[MODE] Greenfield deploy (target missing)"
|
||||||
|
else
|
||||||
|
echo "[MODE] Smart update (target exists)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Common: ensure repo exists, pull + build ---
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
if [ ! -d "$REPO_DIR" ]; then
|
||||||
|
echo "[1/6] Cloning repository..."
|
||||||
|
git clone "$REPO_URL" "$REPO_DIR"
|
||||||
|
elif [ -d "$REPO_DIR/.git" ]; then
|
||||||
|
echo "[1/6] Pulling latest code..."
|
||||||
|
cd "$REPO_DIR" && git pull
|
||||||
|
else
|
||||||
|
echo "[1/6] Using existing code..."
|
||||||
|
fi
|
||||||
|
cd "$REPO_DIR" 2>/dev/null || mkdir -p "$REPO_DIR"
|
||||||
|
|
||||||
|
echo "[2/6] Building binary and helper tool..."
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
go build -o "$BINARY_NAME" .
|
||||||
|
|
||||||
|
# Build helper tool from script directory (has correct go.mod with all deps)
|
||||||
|
TOOL_BIN="/tmp/nextwks-tool"
|
||||||
|
TOOL_SRC="$SCRIPT_DIR/tools/nextwks-tool"
|
||||||
|
if [ -d "$TOOL_SRC" ]; then
|
||||||
|
cd "$TOOL_SRC"
|
||||||
|
go build -o "$TOOL_BIN" . 2>&1 && echo "[OK] Helper tool built" || echo "[WARN] Helper tool build failed"
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install lego if not present (for LE certificate management)
|
||||||
|
if ! command -v lego &>/dev/null && [ -f "$TOOL_BIN" ]; then
|
||||||
|
echo "[*] Installing lego ACME client..."
|
||||||
|
"$TOOL_BIN" install-lego 2>&1 || echo "[WARN] Lego install failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Greenfield path ---
|
||||||
|
if [ "$GREENFIELD" = true ]; then
|
||||||
|
|
||||||
|
# Step 3: Full teardown — stop, disable, remove all services
|
||||||
|
echo "[3/6] Full service teardown..."
|
||||||
|
systemctl stop $SERVICE_NAME 2>/dev/null || true
|
||||||
|
systemctl disable $SERVICE_NAME 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/$SERVICE_NAME.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
podman rm -f zoraxy 2>/dev/null || true
|
||||||
|
# Wait for port 80 to be released
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if ! ss -tlnp | grep -q ':80 '; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " Waiting for port 80 to be released... ($i/10)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Wipe production directory
|
||||||
|
if [ -d "$TARGET_DIR" ]; then
|
||||||
|
chattr -R -i "$TARGET_DIR" 2>/dev/null || true
|
||||||
|
rm -rf "$TARGET_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: Run helper tool — port 80 is free, can obtain LE certs via HTTP-01
|
||||||
|
echo "[4/6] Checking certificates..."
|
||||||
|
mkdir -p "$BACKUP_DIR/certificates"
|
||||||
|
# Build list of active subdomains (skip empty ones)
|
||||||
|
ALL_SUBS=""
|
||||||
|
for S in $APP_SUB $DNS_SUB $WWW_SUB; do
|
||||||
|
[ -n "$S" ] && ALL_SUBS="${ALL_SUBS:+$ALL_SUBS,}${S}.${DOMAIN}"
|
||||||
|
done
|
||||||
|
if [ -f "$TOOL_BIN" ] && [ -n "$ALL_SUBS" ]; then
|
||||||
|
DRY=""
|
||||||
|
[ "${LE_DRY_RUN:-false}" = "true" ] && DRY="--dry-run"
|
||||||
|
"$TOOL_BIN" cert \
|
||||||
|
--domains "$ALL_SUBS" \
|
||||||
|
--email "${TLS_EMAIL:-admin@${DOMAIN}}" \
|
||||||
|
--backup-dir "$BACKUP_DIR/certificates" $DRY 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Build production directory structure
|
||||||
|
echo "[5/6] Building production directory structure..."
|
||||||
|
mkdir -p "$TARGET_DIR/config/nextworkspace"
|
||||||
|
mkdir -p "$TARGET_DIR/config/zoraxy/conf/proxy"
|
||||||
|
mkdir -p "$TARGET_DIR/config/zoraxy/conf/certs"
|
||||||
|
mkdir -p "$TARGET_DIR/config/zoraxy/www/html"
|
||||||
|
mkdir -p "$TARGET_DIR/data/zoraxy"
|
||||||
|
mkdir -p "$TARGET_DIR/compose"
|
||||||
|
mkdir -p "$TARGET_DIR/logs"
|
||||||
|
|
||||||
|
# Copy .env from backup vault
|
||||||
|
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||||
|
cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env"
|
||||||
|
chmod 600 "$TARGET_DIR/.env"
|
||||||
|
echo "[INFO] .env deployed from backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy certificates from backup vault to Zoraxy cert dir
|
||||||
|
for S in $APP_SUB $DNS_SUB $WWW_SUB; do
|
||||||
|
[ -z "$S" ] && continue
|
||||||
|
FQDN="${S}.${DOMAIN}"
|
||||||
|
CERT_SRC="$BACKUP_DIR/certificates/${FQDN}/fullchain.pem"
|
||||||
|
KEY_SRC="$BACKUP_DIR/certificates/${FQDN}/privkey.pem"
|
||||||
|
if [ -f "$CERT_SRC" ] && [ -f "$KEY_SRC" ]; then
|
||||||
|
cp "$CERT_SRC" "$TARGET_DIR/config/zoraxy/conf/certs/${FQDN}.pem"
|
||||||
|
cp "$KEY_SRC" "$TARGET_DIR/config/zoraxy/conf/certs/${FQDN}.key"
|
||||||
|
echo "[INFO] Cert deployed: ${FQDN}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Copy binary
|
||||||
|
echo "[6/6] Deploying..."
|
||||||
|
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
||||||
|
if [ -f "$REPO_DIR/VERSION" ]; then
|
||||||
|
cp "$REPO_DIR/VERSION" "$TARGET_DIR/VERSION"
|
||||||
|
echo "[INFO] Version: $(cat $TARGET_DIR/VERSION)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write ACME config + BoltDB (before Zoraxy starts, db not locked)
|
||||||
|
mkdir -p "$TARGET_DIR/config/zoraxy/conf"
|
||||||
|
cat > "$TARGET_DIR/config/zoraxy/conf/acme_conf.json" <<EOF
|
||||||
|
{
|
||||||
|
"Enabled": true,
|
||||||
|
"Email": "${TLS_EMAIL}",
|
||||||
|
"RenewAll": true,
|
||||||
|
"FilesToRenew": [],
|
||||||
|
"DNSServers": ""
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Seed admin into BoltDB
|
||||||
|
if [ -f "$TOOL_BIN" ]; then
|
||||||
|
mkdir -p "$TARGET_DIR/data/zoraxy"
|
||||||
|
touch "$TARGET_DIR/data/zoraxy/sys.db"
|
||||||
|
"$TOOL_BIN" db --db "$TARGET_DIR/data/zoraxy/sys.db" \
|
||||||
|
--set "system:admin:{\"username\":\"${ADMIN_USERNAME:-master}\"}" 2>&1 || true
|
||||||
|
"$TOOL_BIN" db --db "$TARGET_DIR/data/zoraxy/sys.db" \
|
||||||
|
--set "zorxauth:options:{\"enable_auth_gateway\":true,\"sso_redirect_url\":\"https://${APP_SUB}.${DOMAIN}/\"}" 2>&1 || true
|
||||||
|
echo " [OK] Admin seeded into BoltDB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Zoraxy
|
||||||
|
cp compose/zoraxy.yaml "$TARGET_DIR/compose/zoraxy.yaml"
|
||||||
|
podman rm -f zoraxy 2>/dev/null || true
|
||||||
|
podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" up -d 2>&1 || echo "[WARN] Zoraxy deploy had issues"
|
||||||
|
|
||||||
|
# Wait for Zoraxy to be ready
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if curl -sf --max-time 3 "http://127.0.0.1:8000/" > /dev/null 2>&1; then break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Register certificates using Go tool (handles CSRF/session properly)
|
||||||
|
echo "[*] Registering certificates via Go tool..."
|
||||||
|
REG_TOOL="$REPO_DIR/tools/register-certs/register-certs"
|
||||||
|
# Build list of domains
|
||||||
|
DOMAIN_LIST=""
|
||||||
|
for S in $APP_SUB $DNS_SUB $WWW_SUB; do
|
||||||
|
[ -n "$S" ] && DOMAIN_LIST="$DOMAIN_LIST ${S}.${DOMAIN}"
|
||||||
|
done
|
||||||
|
if [ -n "$DOMAIN_LIST" ]; then
|
||||||
|
if [ ! -f "$REG_TOOL" ]; then
|
||||||
|
cd "$REPO_DIR/tools/register-certs" && go build -o register-certs . 2>/dev/null && cd "$REPO_DIR"
|
||||||
|
fi
|
||||||
|
if [ -f "$REG_TOOL" ]; then
|
||||||
|
"$REG_TOOL" "${ADMIN_USERNAME:-master}" "${ADMIN_PASSWORD:-9Aku7MfklZU9ldnZ}" $DOMAIN_LIST 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop Zoraxy to write proxy configs
|
||||||
|
|
||||||
|
cat > "$TARGET_DIR/config/zoraxy/conf/proxy/${APP_SUB}.$DOMAIN.config" <<ZORAXY_APP
|
||||||
|
{
|
||||||
|
"ProxyType": 1,
|
||||||
|
"RootOrMatchingDomain": "${APP_SUB}.$DOMAIN",
|
||||||
|
"ActiveOrigins": [{"OriginIpOrDomain": "127.0.0.1:9000", "RequireTLS": false, "Weight": 1, "MaxConn": 0}],
|
||||||
|
"Disabled": false, "AuthenticationProvider": {"AuthMethod": 0}
|
||||||
|
}
|
||||||
|
ZORAXY_APP
|
||||||
|
|
||||||
|
cat > "$TARGET_DIR/config/zoraxy/conf/proxy/${DNS_SUB}.$DOMAIN.config" <<ZORAXY_DNS
|
||||||
|
{
|
||||||
|
"ProxyType": 1,
|
||||||
|
"RootOrMatchingDomain": "${DNS_SUB}.$DOMAIN",
|
||||||
|
"ActiveOrigins": [{"OriginIpOrDomain": "127.0.0.1:8000", "RequireTLS": false, "Weight": 1, "MaxConn": 0}],
|
||||||
|
"Disabled": false, "AuthenticationProvider": {"AuthMethod": 0}
|
||||||
|
}
|
||||||
|
ZORAXY_DNS
|
||||||
|
|
||||||
|
if [ -n "$WWW_SUB" ]; then
|
||||||
|
cat > "$TARGET_DIR/config/zoraxy/conf/proxy/${WWW_SUB}.$DOMAIN.config" <<ZORAXY_WWW
|
||||||
|
{
|
||||||
|
"ProxyType": 1,
|
||||||
|
"RootOrMatchingDomain": "${WWW_SUB}.$DOMAIN",
|
||||||
|
"ActiveOrigins": [{"OriginIpOrDomain": "127.0.0.1:9000", "RequireTLS": false, "Weight": 1, "MaxConn": 0}],
|
||||||
|
"Disabled": false, "AuthenticationProvider": {"AuthMethod": 0}
|
||||||
|
}
|
||||||
|
ZORAXY_WWW
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$AUTH_SUB" ]; then
|
||||||
|
cat > "$TARGET_DIR/config/zoraxy/conf/proxy/${AUTH_SUB}.$DOMAIN.config" <<ZORAXY_AUTH
|
||||||
|
{
|
||||||
|
"ProxyType": 1,
|
||||||
|
"RootOrMatchingDomain": "${AUTH_SUB}.$DOMAIN",
|
||||||
|
"ActiveOrigins": [{"OriginIpOrDomain": "127.0.0.1:5489", "RequireTLS": false, "Weight": 1, "MaxConn": 0}],
|
||||||
|
"Disabled": false, "AuthenticationProvider": {"AuthMethod": 0}
|
||||||
|
}
|
||||||
|
ZORAXY_AUTH
|
||||||
|
fi
|
||||||
|
|
||||||
|
chattr -R +i "$TARGET_DIR/config/zoraxy/conf/proxy/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy landing page + launcher config
|
||||||
|
cp -r config/www/* "$TARGET_DIR/config/zoraxy/www/html/"
|
||||||
|
cp -r config/nextworkspace/* "$TARGET_DIR/config/nextworkspace/"
|
||||||
|
|
||||||
|
# Generate apps.yaml
|
||||||
|
echo "[*] Generating apps.yaml..."
|
||||||
|
cat > "$TARGET_DIR/config/nextworkspace/apps.yaml" <<EOF
|
||||||
|
apps:
|
||||||
|
- name: "OpenCloud"
|
||||||
|
subtitle: "File Storage"
|
||||||
|
path: "/cloud"
|
||||||
|
upstream: "http://127.0.0.1:9100"
|
||||||
|
icon: "cloud"
|
||||||
|
- name: "Euro Office"
|
||||||
|
subtitle: "Collaborative Suite"
|
||||||
|
path: "/office"
|
||||||
|
upstream: "http://127.0.0.1:9200"
|
||||||
|
icon: "office"
|
||||||
|
- name: "ERPNext"
|
||||||
|
subtitle: "Enterprise ERP"
|
||||||
|
path: "/erp"
|
||||||
|
upstream: "http://127.0.0.1:9300"
|
||||||
|
icon: "erp"
|
||||||
|
- name: "Matrix Chat"
|
||||||
|
subtitle: "Team Communication"
|
||||||
|
path: "/chat"
|
||||||
|
upstream: "http://127.0.0.1:9400"
|
||||||
|
icon: "chat"
|
||||||
|
- name: "Jitsi"
|
||||||
|
subtitle: "Video Conferencing"
|
||||||
|
path: "/meet"
|
||||||
|
upstream: "http://127.0.0.1:9500"
|
||||||
|
icon: "video"
|
||||||
|
- name: "Webmail"
|
||||||
|
subtitle: "Email Client"
|
||||||
|
path: "/mail"
|
||||||
|
upstream: "http://127.0.0.1:9600"
|
||||||
|
icon: "mail"
|
||||||
|
- name: "AI Chat"
|
||||||
|
subtitle: "Open WebUI"
|
||||||
|
path: "/ai"
|
||||||
|
upstream: "http://127.0.0.1:9700"
|
||||||
|
icon: "bot"
|
||||||
|
- name: "Portainer"
|
||||||
|
subtitle: "Container Management"
|
||||||
|
path: "/admin"
|
||||||
|
upstream: "http://127.0.0.1:9800"
|
||||||
|
icon: "admin"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Remove stale proxy.old dirs created by Zoraxy
|
||||||
|
rm -rf "$TARGET_DIR/config/zoraxy/conf/proxy.old" "$TARGET_DIR/config/zoraxy/conf/proxy-314.old" "$TARGET_DIR/config/zoraxy/conf/proxy-321.old" 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- Phase 4: Start Zoraxy with all configs ---
|
||||||
|
echo "[*] Phase 4: Starting Zoraxy with custom configs..."
|
||||||
|
podman start zoraxy 2>/dev/null || podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" up -d 2>&1 || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Write systemd service
|
||||||
|
echo "[*] Writing systemd service..."
|
||||||
|
cat > /etc/systemd/system/$SERVICE_NAME.service <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=NextWorkspace Launcher
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Environment=CONFIG_DIR=$TARGET_DIR/config/nextworkspace
|
||||||
|
EnvironmentFile=$BACKUP_DIR/.env
|
||||||
|
ExecStart=$TARGET_DIR/$BINARY_NAME
|
||||||
|
WorkingDirectory=$TARGET_DIR
|
||||||
|
Restart=always
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now $SERVICE_NAME
|
||||||
|
|
||||||
|
# --- Smart update path ---
|
||||||
|
else
|
||||||
|
echo "[3/6] Stopping launcher..."
|
||||||
|
systemctl stop $SERVICE_NAME 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[4/6] Swapping binary..."
|
||||||
|
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
||||||
|
|
||||||
|
echo "[5/6] Refreshing configs..."
|
||||||
|
if [ -f config/nextworkspace/apps.yaml ]; then
|
||||||
|
cp config/nextworkspace/apps.yaml "$TARGET_DIR/config/nextworkspace/apps.yaml"
|
||||||
|
fi
|
||||||
|
if [ -d config/zoraxy/conf/proxy ]; then
|
||||||
|
cp config/zoraxy/conf/proxy/* "$TARGET_DIR/config/zoraxy/conf/proxy/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[6/6] Restarting Zoraxy and launcher..."
|
||||||
|
podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" restart 2>&1 || true
|
||||||
|
systemctl restart $SERVICE_NAME
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Health check ---
|
||||||
|
echo "[*] Running health check..."
|
||||||
|
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
||||||
|
if curl -sf http://127.0.0.1:9000/health > /dev/null 2>&1; then
|
||||||
|
echo "[OK] NextWorkspace launcher is healthy on http://127.0.0.1:9000/"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo " Attempt $i/$HEALTH_CHECK_RETRIES — not ready yet..."
|
||||||
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[FAIL] Health check failed — launcher did not respond on port 9000"
|
||||||
|
echo "[DEBUG] Checking systemd service status..."
|
||||||
|
systemctl status $SERVICE_NAME --no-pager 2>&1 | head -10 || true
|
||||||
|
journalctl -u $SERVICE_NAME --no-pager -n 10 2>&1 || true
|
||||||
|
exit 1
|
||||||
1
go.mod
1
go.mod
|
|
@ -8,7 +8,6 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/crypto v0.53.0 // indirect
|
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -6,8 +6,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
|
|
||||||
109
install.sh
Executable file
109
install.sh
Executable file
|
|
@ -0,0 +1,109 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# NextWorkspace Installer — bootstraps a bare Linux VM
|
||||||
|
# Idempotent: safe to run multiple times.
|
||||||
|
|
||||||
|
TARGET_DIR="/opt/nextworkspace"
|
||||||
|
BACKUP_DIR="/opt/backup"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# --- Create backup vault and runtime directories ---
|
||||||
|
echo "=== NextWorkspace Setup ==="
|
||||||
|
mkdir -p "$BACKUP_DIR/certificates"
|
||||||
|
mkdir -p "$TARGET_DIR/config/nextworkspace"
|
||||||
|
mkdir -p "$TARGET_DIR/config/zoraxy/conf/proxy"
|
||||||
|
mkdir -p "$TARGET_DIR/config/zoraxy/www/html"
|
||||||
|
mkdir -p "$TARGET_DIR/data/zoraxy"
|
||||||
|
mkdir -p "$TARGET_DIR/compose"
|
||||||
|
mkdir -p "$TARGET_DIR/logs"
|
||||||
|
|
||||||
|
# --- Interactive prompts (no defaults — user enters everything) ---
|
||||||
|
read -p "Domain: " DOMAIN
|
||||||
|
while [ -z "$DOMAIN" ]; do
|
||||||
|
read -p "Domain (required): " DOMAIN
|
||||||
|
done
|
||||||
|
|
||||||
|
read -p "TLS email (Let's Encrypt): " TLS_EMAIL
|
||||||
|
while [ -z "$TLS_EMAIL" ]; do
|
||||||
|
read -p "TLS email (required): " TLS_EMAIL
|
||||||
|
done
|
||||||
|
# Basic email validation (must contain @)
|
||||||
|
while echo "$TLS_EMAIL" | grep -qv '@'; do
|
||||||
|
read -p "Invalid email — must contain @: " TLS_EMAIL
|
||||||
|
done
|
||||||
|
|
||||||
|
read -p "Admin username: " ADMIN_USERNAME
|
||||||
|
while [ -z "$ADMIN_USERNAME" ]; do
|
||||||
|
read -p "Admin username (required): " ADMIN_USERNAME
|
||||||
|
done
|
||||||
|
|
||||||
|
# Generate 12-char alphanumeric password (easy to type)
|
||||||
|
ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 12 2>/dev/null || date +%s | head -c 12)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " Domain: $DOMAIN"
|
||||||
|
echo " TLS email: $TLS_EMAIL"
|
||||||
|
echo " Admin username: $ADMIN_USERNAME"
|
||||||
|
echo " Admin password: $ADMIN_PASSWORD"
|
||||||
|
echo " Save this password — it won't be shown again!"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Write .env file in backup vault (deploy.sh copies it to production)
|
||||||
|
ENV_FILE="$BACKUP_DIR/.env"
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
# NextWorkspace Configuration
|
||||||
|
# This file is auto-generated by install.sh — do not edit manually
|
||||||
|
DOMAIN=$DOMAIN
|
||||||
|
TLS_EMAIL=$TLS_EMAIL
|
||||||
|
ADMIN_USERNAME=$ADMIN_USERNAME
|
||||||
|
ADMIN_PASSWORD=$ADMIN_PASSWORD
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# ---- Go ----
|
||||||
|
if command -v go &>/dev/null; then
|
||||||
|
echo "[SKIP] Go already installed: $(go version)"
|
||||||
|
else
|
||||||
|
echo "[INSTALL] Installing Go..."
|
||||||
|
GO_URL="https://go.dev/dl/$(curl -sL https://go.dev/VERSION?m=text | head -1).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
|
||||||
|
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
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
echo "[OK] Go installed: $(go version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- System deps ----
|
||||||
|
echo "[INSTALL] git, build-essential, podman, podman-compose..."
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq git build-essential curl podman podman-compose
|
||||||
|
|
||||||
|
# ---- Deploy: use current scripts + tools ---
|
||||||
|
REPO_DIR="/opt/NextWks"
|
||||||
|
mkdir -p "$REPO_DIR"
|
||||||
|
|
||||||
|
# Clone or update repo, then overlay our current code
|
||||||
|
echo "[SETUP] Preparing /opt/NextWks..."
|
||||||
|
if [ -d "$REPO_DIR/.git" ]; then
|
||||||
|
cd "$REPO_DIR" && git pull
|
||||||
|
elif command -v git &>/dev/null; then
|
||||||
|
# Try to clone the remote first, so git history is intact
|
||||||
|
git clone "https://git.lohmar.co.uk/lexton-it/NextWks.git" "$REPO_DIR.tmp" 2>/dev/null && \
|
||||||
|
mv "$REPO_DIR.tmp" "$REPO_DIR" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy current code on top (ensures latest changes)
|
||||||
|
mkdir -p "$REPO_DIR"
|
||||||
|
cp -r "$SCRIPT_DIR"/* "$REPO_DIR/"
|
||||||
|
cp "$SCRIPT_DIR"/.gitignore "$REPO_DIR/" 2>/dev/null || true
|
||||||
|
chmod +x "$REPO_DIR/deploy.sh" "$REPO_DIR/install.sh" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[DONE] Bootstrapping complete. Running first deploy..."
|
||||||
|
"$REPO_DIR/deploy.sh" --destroy
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
page_title: "Admin Bereich"
|
|
||||||
nav_global: "Global"
|
|
||||||
nav_access: "Zugriff"
|
|
||||||
nav_security: "Sicherheit"
|
|
||||||
nav_domain: "Domain"
|
|
||||||
nav_mail: "E-Mail"
|
|
||||||
nav_docs: "Dokumente"
|
|
||||||
nav_calendar: "Kalender"
|
|
||||||
save: "Einstellungen speichern"
|
|
||||||
saved: "✓ Gespeichert"
|
|
||||||
cancel: "Abbrechen"
|
|
||||||
company_section: "Unternehmen"
|
|
||||||
company_name: "Unternehmensname"
|
|
||||||
company_subtitle: "Untertitel"
|
|
||||||
company_logo: "Logo URL"
|
|
||||||
language: "Standardsprache"
|
|
||||||
timezone: "Zeitzone"
|
|
||||||
smtp_section: "SMTP"
|
|
||||||
smtp_host: "SMTP Host"
|
|
||||||
smtp_port: "SMTP Port"
|
|
||||||
smtp_user: "SMTP Benutzer"
|
|
||||||
smtp_password: "SMTP Passwort"
|
|
||||||
smtp_sender: "Absender E-Mail"
|
|
||||||
system_section: "System (schreibgeschützt)"
|
|
||||||
domain_label: "Domain"
|
|
||||||
admin_email: "Admin E-Mail"
|
|
||||||
authelia_status: "Authelia"
|
|
||||||
version_label: "Version"
|
|
||||||
global_title: "Globale Einstellungen"
|
|
||||||
access_title: "Zugriffsverwaltung"
|
|
||||||
users_label: "Benutzer"
|
|
||||||
create_user: "Benutzer anlegen"
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
app_title: "NextWorkspace"
|
|
||||||
welcome: "Willkommen, {user}"
|
|
||||||
login: "Anmelden"
|
|
||||||
logout: "Abmelden"
|
|
||||||
launcher_title: "Ihr Arbeitsbereich"
|
|
||||||
no_apps: "Keine Anwendungen verfügbar"
|
|
||||||
admin_panel: "Admin Bereich"
|
|
||||||
settings: "Einstellungen"
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
page_title: "Admin Panel"
|
|
||||||
nav_global: "Global"
|
|
||||||
nav_access: "Access"
|
|
||||||
nav_security: "Security"
|
|
||||||
nav_domain: "Domain"
|
|
||||||
nav_mail: "Mail"
|
|
||||||
nav_docs: "Docs"
|
|
||||||
nav_calendar: "Calendar"
|
|
||||||
save: "Save Settings"
|
|
||||||
saved: "✓ Saved"
|
|
||||||
cancel: "Cancel"
|
|
||||||
company_section: "Company"
|
|
||||||
company_name: "Company Name"
|
|
||||||
company_subtitle: "Subtitle"
|
|
||||||
company_logo: "Logo URL"
|
|
||||||
language: "Default Language"
|
|
||||||
timezone: "Timezone"
|
|
||||||
smtp_section: "SMTP"
|
|
||||||
smtp_host: "SMTP Host"
|
|
||||||
smtp_port: "SMTP Port"
|
|
||||||
smtp_user: "SMTP User"
|
|
||||||
smtp_password: "SMTP Password"
|
|
||||||
smtp_sender: "Sender Email"
|
|
||||||
system_section: "System (read-only)"
|
|
||||||
domain_label: "Domain"
|
|
||||||
admin_email: "Admin Email"
|
|
||||||
authelia_status: "Authelia"
|
|
||||||
version_label: "Version"
|
|
||||||
global_title: "Global Settings"
|
|
||||||
access_title: "Access Management"
|
|
||||||
users_label: "Users"
|
|
||||||
create_user: "Create User"
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
app_title: "NextWorkspace"
|
|
||||||
welcome: "Welcome, {user}"
|
|
||||||
login: "Sign In"
|
|
||||||
logout: "Logout"
|
|
||||||
launcher_title: "Your Workspace"
|
|
||||||
no_apps: "No applications available"
|
|
||||||
admin_panel: "Admin Panel"
|
|
||||||
settings: "Settings"
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
#!/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."
|
|
||||||
Binary file not shown.
|
|
@ -1,21 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if len(os.Args) < 2 {
|
|
||||||
fmt.Fprintln(os.Stderr, "Usage: hash-password <password>")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(os.Args[1]), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Print(string(hash))
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Manage users via authelia-api
|
|
||||||
# Usage: ./manage-users.sh list
|
|
||||||
# ./manage-users.sh create username displayname email groups...
|
|
||||||
# ./manage-users.sh delete username
|
|
||||||
|
|
||||||
API_BASE="http://127.0.0.1:8080"
|
|
||||||
TOKEN=$(grep -oP 'session_secret: \K.*' /opt/nextworkspace/config/authelia/configuration.yml)
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
list)
|
|
||||||
curl -s -H "Authorization: Bearer $TOKEN" "$API_BASE/api/users" | jq . 2>/dev/null || \
|
|
||||||
curl -s -H "Authorization: Bearer $TOKEN" "$API_BASE/api/users"
|
|
||||||
;;
|
|
||||||
create)
|
|
||||||
shift
|
|
||||||
if [ $# -lt 3 ]; then
|
|
||||||
echo "Usage: $0 create username displayname email [groups...]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
USERNAME="$1"; DISPLAY="$2"; EMAIL="$3"; shift 3
|
|
||||||
GROUPS='["users"'
|
|
||||||
for g in "$@"; do GROUPS="$GROUPS,\"$g\""; done
|
|
||||||
GROUPS="$GROUPS]"
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"users\":[{\"username\":\"$USERNAME\",\"display_name\":\"$DISPLAY\",\"email\":\"$EMAIL\",\"groups\":$GROUPS}]}" \
|
|
||||||
"$API_BASE/api/users/bulk"
|
|
||||||
;;
|
|
||||||
delete)
|
|
||||||
if [ -z "${2:-}" ]; then
|
|
||||||
echo "Usage: $0 delete username" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$API_BASE/api/users/$2"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 {list|create|delete} ..." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
381
tools/nextwks-tool/main.go
Normal file
381
tools/nextwks-tool/main.go
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
log.Fatalf("Usage: %s <cert|db|install-lego> [flags]", os.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "cert":
|
||||||
|
runCert(os.Args[2:])
|
||||||
|
case "db":
|
||||||
|
runDB(os.Args[2:])
|
||||||
|
case "install-lego":
|
||||||
|
runInstallLego()
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unknown command: %s (use cert, db, or install-lego)", os.Args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lego install ---
|
||||||
|
|
||||||
|
func runInstallLego() {
|
||||||
|
arch, err := exec.Command("uname", "-m").Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to detect architecture: %v", err)
|
||||||
|
}
|
||||||
|
archStr := strings.TrimSpace(string(arch))
|
||||||
|
archStr = strings.ReplaceAll(archStr, "x86_64", "amd64")
|
||||||
|
archStr = strings.ReplaceAll(archStr, "aarch64", "arm64")
|
||||||
|
|
||||||
|
// Get latest version
|
||||||
|
out, err := exec.Command("curl", "-fsSL",
|
||||||
|
"https://api.github.com/repos/go-acme/lego/releases/latest").Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to fetch latest lego version: %v", err)
|
||||||
|
}
|
||||||
|
version := extractLegoVersion(string(out))
|
||||||
|
if version == "" {
|
||||||
|
log.Fatalf("Could not parse latest lego version")
|
||||||
|
}
|
||||||
|
|
||||||
|
tarball := fmt.Sprintf("lego_%s_linux_%s.tar.gz", version, archStr)
|
||||||
|
url := fmt.Sprintf("https://github.com/go-acme/lego/releases/download/%s/%s", version, tarball)
|
||||||
|
|
||||||
|
log.Printf("Downloading lego %s for %s...", version, archStr)
|
||||||
|
if err := exec.Command("curl", "-fLO", url).Run(); err != nil {
|
||||||
|
log.Fatalf("Failed to download lego: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Installing to /usr/local/bin/...")
|
||||||
|
if err := exec.Command("sudo", "tar", "-xzf", tarball, "-C", "/usr/local/bin", "lego").Run(); err != nil {
|
||||||
|
log.Fatalf("Failed to extract lego: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Remove(tarball)
|
||||||
|
log.Printf("[OK] lego %s installed at /usr/local/bin/lego", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLegoVersion(body string) string {
|
||||||
|
// Simple parser for the GitHub API response
|
||||||
|
marker := `"tag_name": "`
|
||||||
|
idx := strings.Index(body, marker)
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := body[idx+len(marker):]
|
||||||
|
end := strings.Index(rest, `"`)
|
||||||
|
if end < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return rest[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cert command ---
|
||||||
|
|
||||||
|
func runCert(args []string) {
|
||||||
|
fs := flag.NewFlagSet("cert", flag.ExitOnError)
|
||||||
|
domainsStr := fs.String("domains", "", "Comma-separated domain list")
|
||||||
|
email := fs.String("email", "", "ACME email")
|
||||||
|
backupDir := fs.String("backup-dir", "/opt/backup/certificates", "Backup directory for certs")
|
||||||
|
legoDir := fs.String("lego-dir", "/opt/backup", "Lego working directory")
|
||||||
|
dryRun := fs.Bool("dry-run", false, "Skip LE, generate self-signed if no backup")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
if *domainsStr == "" || *email == "" {
|
||||||
|
log.Fatal("--domains and --email are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
domains := strings.Split(*domainsStr, ",")
|
||||||
|
for i := range domains {
|
||||||
|
domains[i] = strings.TrimSpace(domains[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(*backupDir, 0755); err != nil {
|
||||||
|
log.Fatalf("Failed to create backup dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all domains have valid LE certs in backup or lego cache
|
||||||
|
needIssue := false
|
||||||
|
for _, domain := range domains {
|
||||||
|
certFile := findCertFile(domain, *backupDir, *legoDir)
|
||||||
|
if certFile == "" {
|
||||||
|
needIssue = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !isCertFromLE(certFile) {
|
||||||
|
log.Printf("[INFO] %s: cert is self-signed, reissuing", domain)
|
||||||
|
needIssue = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if isCertExpired(certFile, 30*24*time.Hour) {
|
||||||
|
log.Printf("[INFO] %s: cert expires soon, reissuing", domain)
|
||||||
|
needIssue = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needIssue {
|
||||||
|
if *dryRun {
|
||||||
|
log.Printf("[INFO] Dry-run mode — generating self-signed fallback certs")
|
||||||
|
generateSelfSigned(domains, *backupDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Try lego first
|
||||||
|
legoPath, legoErr := exec.LookPath("lego")
|
||||||
|
if legoErr == nil {
|
||||||
|
log.Printf("[INFO] Requesting LE certificates via lego...")
|
||||||
|
if err := obtainCertsLego(domains, *email, *backupDir, *legoDir, legoPath); err != nil {
|
||||||
|
log.Printf("[WARN] lego failed: %v", err)
|
||||||
|
log.Printf("[INFO] Generating self-signed fallback certs")
|
||||||
|
generateSelfSigned(domains, *backupDir)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("[WARN] lego not found (install with 'nextwks-tool install-lego')")
|
||||||
|
log.Printf("[INFO] Generating self-signed fallback certs")
|
||||||
|
generateSelfSigned(domains, *backupDir)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("[OK] All certificates found in backup (dry-run)")
|
||||||
|
for _, domain := range domains {
|
||||||
|
certFile := filepath.Join(*backupDir, domain, "fullchain.pem")
|
||||||
|
expiry := getCertExpiry(certFile)
|
||||||
|
log.Printf(" %s — expires %s", domain, expiry.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func obtainCertsLego(domains []string, email, backupDir, legoDir, legoPath string) error {
|
||||||
|
if err := os.MkdirAll(legoDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating lego dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certDir := filepath.Join(legoDir, "certificates")
|
||||||
|
|
||||||
|
for _, domain := range domains {
|
||||||
|
log.Printf(" Requesting cert for %s...", domain)
|
||||||
|
args := []string{"run",
|
||||||
|
"--http",
|
||||||
|
"--http.address=:80",
|
||||||
|
"--path", legoDir,
|
||||||
|
"--accept-tos",
|
||||||
|
"-m", email,
|
||||||
|
"-d", domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, legoPath, args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
log.Printf(" [WARN] lego failed for %s: %v", domain, err)
|
||||||
|
// Don't try remaining domains — caller will generate self-signed fallbacks
|
||||||
|
return fmt.Errorf("lego failed for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay so port 80 is released between runs
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Copy cert to per-domain backup
|
||||||
|
crtSrc := filepath.Join(certDir, domain+".crt")
|
||||||
|
keySrc := filepath.Join(certDir, domain+".key")
|
||||||
|
domainDir := filepath.Join(backupDir, domain)
|
||||||
|
os.MkdirAll(domainDir, 0755)
|
||||||
|
|
||||||
|
if fileExists(crtSrc) && fileExists(keySrc) {
|
||||||
|
copyFile(crtSrc, filepath.Join(domainDir, "fullchain.pem"))
|
||||||
|
copyFile(keySrc, filepath.Join(domainDir, "privkey.pem"))
|
||||||
|
log.Printf("[OK] Certificate obtained for %s", domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DB command ---
|
||||||
|
|
||||||
|
func runDB(args []string) {
|
||||||
|
fs := flag.NewFlagSet("db", flag.ExitOnError)
|
||||||
|
dbPath := fs.String("db", "", "Path to BoltDB file")
|
||||||
|
set := fs.String("set", "", "bucket:key:json-value")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
if *dbPath == "" || *set == "" {
|
||||||
|
log.Fatal("--db and --set are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(*set, ":", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
log.Fatalf("Invalid --set format. Use bucket:key:json-value")
|
||||||
|
}
|
||||||
|
bucket := parts[0]
|
||||||
|
key := parts[1]
|
||||||
|
value := parts[2]
|
||||||
|
|
||||||
|
db, err := bbolt.Open(*dbPath, 0600, &bbolt.Options{Timeout: 1 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to open BoltDB: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
b, err := tx.CreateBucketIfNotExists([]byte(bucket))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.Put([]byte(key), []byte(value))
|
||||||
|
}); err != nil {
|
||||||
|
log.Fatalf("Failed to write to BoltDB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[OK] Wrote %s:%s to %s", bucket, key, *dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func findCertFile(domain, backupDir, legoDir string) string {
|
||||||
|
// Check backup first
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(backupDir, domain, "fullchain.pem"),
|
||||||
|
filepath.Join(legoDir, "certificates", domain+".crt"),
|
||||||
|
}
|
||||||
|
// Lego issues SAN cert named after first domain — check in lego cache
|
||||||
|
entries, _ := os.ReadDir(filepath.Join(legoDir, "certificates"))
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".crt") && !strings.Contains(e.Name(), ".issuer.") {
|
||||||
|
candidates = append(candidates, filepath.Join(legoDir, "certificates", e.Name()))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if fileExists(c) && isCertFromLE(c) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCertFromLE(certFile string) bool {
|
||||||
|
data, err := os.ReadFile(certFile)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(cert.Issuer.Organization) > 0 {
|
||||||
|
return strings.Contains(cert.Issuer.Organization[0], "Let's Encrypt")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCertExpired(certFile string, threshold time.Duration) bool {
|
||||||
|
data, err := os.ReadFile(certFile)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Now().Add(threshold).After(cert.NotAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCertExpiry(certFile string) time.Time {
|
||||||
|
data, err := os.ReadFile(certFile)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return cert.NotAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(dst, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSelfSigned(domains []string, backupDir string) error {
|
||||||
|
for _, domain := range domains {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: domain},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
domainDir := filepath.Join(backupDir, domain)
|
||||||
|
os.MkdirAll(domainDir, 0755)
|
||||||
|
|
||||||
|
certFile := filepath.Join(domainDir, "fullchain.pem")
|
||||||
|
keyFile := filepath.Join(domainDir, "privkey.pem")
|
||||||
|
|
||||||
|
f, _ := os.Create(certFile)
|
||||||
|
pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
f, _ = os.Create(keyFile)
|
||||||
|
pem.Encode(f, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
log.Printf("[INFO] Self-signed cert generated for %s", domain)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
356
tools/nextwks.sh
356
tools/nextwks.sh
|
|
@ -1,356 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git"
|
|
||||||
BUILD_DIR="/tmp/nextwks-build"
|
|
||||||
TARGET_DIR="/opt/nextworkspace"
|
|
||||||
BACKUP_DIR="/opt/backup"
|
|
||||||
NETWORK_NAME="nextwks-net"
|
|
||||||
HEALTH_CHECK_RETRIES=15
|
|
||||||
HEALTH_CHECK_INTERVAL=3
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage: $0 [--install|--update|--destroy]"
|
|
||||||
echo " --install First-time setup on a bare VM (prompts for config)"
|
|
||||||
echo " --update Smart update: pull, build, copy, bounce containers"
|
|
||||||
echo " --destroy Full greenfield redeploy (uses saved secrets)"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[ $# -eq 0 ] && usage
|
|
||||||
MODE="${1#--}"
|
|
||||||
case "$MODE" in install|update|destroy) ;; *) usage ;; esac
|
|
||||||
|
|
||||||
# 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 [ -r "$TARGET_DIR/.env" ]; then
|
|
||||||
set -a; source "$TARGET_DIR/.env"; set +a
|
|
||||||
fi
|
|
||||||
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 (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..."
|
|
||||||
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_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
|
|
||||||
maybe_sudo rm -rf /usr/local/go
|
|
||||||
maybe_sudo tar -C /usr/local -xzf /tmp/go.tar.gz
|
|
||||||
rm /tmp/go.tar.gz
|
|
||||||
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
|
|
||||||
# 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 "========================================"
|
|
||||||
echo " Domain: $DOMAIN"
|
|
||||||
echo " TLS email: $TLS_EMAIL"
|
|
||||||
echo " Admin username: $ADMIN_USERNAME"
|
|
||||||
echo " Admin password: $ADMIN_PASSWORD"
|
|
||||||
echo " Save this password — it won't be shown again!"
|
|
||||||
echo "========================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
read -p "SMTP host [smtp.openxchange.eu]: " SMTP_HOST; SMTP_HOST="${SMTP_HOST:-smtp.openxchange.eu}"
|
|
||||||
read -p "SMTP port [587]: " SMTP_PORT; SMTP_PORT="${SMTP_PORT:-587}"
|
|
||||||
read -p "SMTP user [post@nextwks.eu]: " SMTP_USER; SMTP_USER="${SMTP_USER:-post@nextwks.eu}"
|
|
||||||
read -sp "SMTP password: " SMTP_PASS; echo ""
|
|
||||||
[ -z "$SMTP_PASS" ] && echo "ERROR: SMTP password required" && exit 1
|
|
||||||
|
|
||||||
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'
|
|
||||||
IMAP_HOST='$IMAP_HOST'
|
|
||||||
IMAP_PORT='$IMAP_PORT'
|
|
||||||
ENVEOF"
|
|
||||||
maybe_sudo chmod 600 "$BACKUP_DIR/.env"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 2. CLONE fresh (every mode — ensures latest code)
|
|
||||||
# ============================================================
|
|
||||||
echo "[*] Cloning repository..."
|
|
||||||
rm -rf "$BUILD_DIR"
|
|
||||||
git clone --depth 1 "$REPO_URL" "$BUILD_DIR"
|
|
||||||
cd "$BUILD_DIR"
|
|
||||||
|
|
||||||
# Save script to user's home for easy future access (--install only)
|
|
||||||
if [ "$MODE" = "install" ]; then
|
|
||||||
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. 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 .
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 5. STOP containers (all modes — binary is mounted, must stop before copy)
|
|
||||||
# ============================================================
|
|
||||||
echo "[*] Stopping containers..."
|
|
||||||
podman stop caddy authelia launcher 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 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
|
|
||||||
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
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 8. COPY artifacts to target (as root)
|
|
||||||
# ============================================================
|
|
||||||
echo "[*] Copying artifacts..."
|
|
||||||
maybe_sudo cp nextworkspace "$TARGET_DIR/nextworkspace"
|
|
||||||
maybe_sudo cp "$BUILD_DIR/VERSION" "$TARGET_DIR/VERSION"
|
|
||||||
if [ -d "$BUILD_DIR/config/www" ]; then
|
|
||||||
maybe_sudo cp -r "$BUILD_DIR/config/www"/* "$TARGET_DIR/www/"
|
|
||||||
fi
|
|
||||||
if [ -d "$BUILD_DIR/lng" ]; then
|
|
||||||
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
|
|
||||||
maybe_sudo cp -r "$BUILD_DIR/config/nextworkspace"/* "$TARGET_DIR/config/nextworkspace/"
|
|
||||||
fi
|
|
||||||
# Restore .env from backup
|
|
||||||
if [ -f "$BACKUP_DIR/.env" ]; then
|
|
||||||
maybe_sudo cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env"
|
|
||||||
maybe_sudo chmod 644 "$TARGET_DIR/.env"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 9. GENERATE config files with placeholder substitution
|
|
||||||
# Write to /tmp first, then sudo cp to target
|
|
||||||
# ============================================================
|
|
||||||
GEN_DIR=$(mktemp -d)
|
|
||||||
trap "rm -rf '$GEN_DIR'" EXIT
|
|
||||||
|
|
||||||
echo "[*] Generating config files..."
|
|
||||||
|
|
||||||
# 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" > "$GEN_DIR/configuration.yml"
|
|
||||||
|
|
||||||
# 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|g" \
|
|
||||||
"$BUILD_DIR/config/authelia/users_database.yml" > "$GEN_DIR/users_database.yml"
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 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..."
|
|
||||||
|
|
||||||
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
|
|
||||||
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" > "$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"
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 12. HEALTH CHECK
|
|
||||||
# ============================================================
|
|
||||||
echo "[*] Running health check..."
|
|
||||||
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
|
||||||
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/"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep $HEALTH_CHECK_INTERVAL
|
|
||||||
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
|
|
||||||
158
tools/register-certs/main.go
Normal file
158
tools/register-certs/main.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: %s <username> <password> <domain> [domain...]\n", os.Args[0])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
username := os.Args[1]
|
||||||
|
password := os.Args[2]
|
||||||
|
domains := os.Args[3:]
|
||||||
|
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := &http.Client{Jar: jar}
|
||||||
|
|
||||||
|
// Step 1: Fetch login page to get CSRF token
|
||||||
|
resp, err := client.Get("http://127.0.0.1:8000/login.html")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "FAIL: fetching login page: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`content="([^"]+)"`)
|
||||||
|
csrf := extractCSRF(string(body), re)
|
||||||
|
if csrf == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "FAIL: could not extract CSRF token\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Login
|
||||||
|
form := url.Values{"username": {username}, "password": {password}}
|
||||||
|
req, _ := http.NewRequest("POST", "http://127.0.0.1:8000/api/auth/login", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("X-CSRF-Token", csrf)
|
||||||
|
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "FAIL: login request: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
body, _ = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 || !strings.Contains(strings.ToLower(string(body)), `"ok"`) {
|
||||||
|
fmt.Fprintf(os.Stderr, "FAIL: login failed (status=%d): %s\n", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("OK: Logged in as %s\n", username)
|
||||||
|
|
||||||
|
// Step 3: Upload cert for each domain using multipart/form-data
|
||||||
|
certsDir := "/opt/nextworkspace/config/zoraxy/conf/certs"
|
||||||
|
success := true
|
||||||
|
for _, domain := range domains {
|
||||||
|
// Try .pem first, then .crt
|
||||||
|
pemPath := filepath.Join(certsDir, domain+".pem")
|
||||||
|
crtPath := filepath.Join(certsDir, domain+".crt")
|
||||||
|
keyPath := filepath.Join(certsDir, domain+".key")
|
||||||
|
|
||||||
|
certData, err := os.ReadFile(pemPath)
|
||||||
|
if err != nil {
|
||||||
|
certData, err = os.ReadFile(crtPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("SKIP: %s (no cert file)\n", domain)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyData, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("WARN: %s (no key file), uploading cert only\n", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build multipart form
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
w.WriteField("domain", domain)
|
||||||
|
|
||||||
|
certWriter, _ := w.CreateFormFile("cert", domain+".pem")
|
||||||
|
certWriter.Write(certData)
|
||||||
|
|
||||||
|
if keyData != nil {
|
||||||
|
keyWriter, _ := w.CreateFormFile("key", domain+".key")
|
||||||
|
keyWriter.Write(keyData)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
// Get fresh CSRF
|
||||||
|
resp, err := client.Get("http://127.0.0.1:8000/login.html")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("WARN: %s csrf fetch failed: %v\n", domain, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
csrf = extractCSRF(string(b), re)
|
||||||
|
if csrf == "" {
|
||||||
|
fmt.Printf("WARN: %s no CSRF token\n", domain)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", "http://127.0.0.1:8000/api/cert/upload", &buf)
|
||||||
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
req.Header.Set("X-CSRF-Token", csrf)
|
||||||
|
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("FAIL: %s request failed: %v\n", domain, err)
|
||||||
|
success = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, _ = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
|
fmt.Printf("OK: %s cert uploaded\n", domain)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("FAIL: %s (status=%d): %s\n", domain, resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCSRF(html string, re *regexp.Regexp) string {
|
||||||
|
match := re.FindAllStringSubmatch(html, -1)
|
||||||
|
for _, m := range match {
|
||||||
|
if len(m) > 1 && len(m[1]) > 20 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try looking for the specific zoraxy.csrf.Token pattern
|
||||||
|
idx := strings.Index(html, "zoraxy.csrf.Token")
|
||||||
|
if idx >= 0 {
|
||||||
|
sub := html[idx:]
|
||||||
|
m := re.FindStringSubmatch(sub)
|
||||||
|
if len(m) > 1 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue