forked from Nixius/authelia
1
0
Fork 0

Compare commits

...

6 Commits

Author SHA1 Message Date
Leopere 8b3ba3ab5a
archive legacy auth stack
Move Authelia and LLDAP artifacts out of the active deployment path so ATLAS ships against the Authentik-backed stack and ss-atlas image only.

Made-with: Cursor
2026-04-25 15:12:47 -04:00
Leopere ec79638f89
correcting success 2026-03-05 15:41:30 -05:00
Leopere 897e1f6b17
bump 2026-03-05 15:36:59 -05:00
Leopere 76e351c7e7
bump 2026-03-05 15:29:21 -05:00
Leopere 71b91a4284
bump 2026-03-05 15:25:35 -05:00
Leopere 630bd3d3f4
bump 2026-03-05 15:20:55 -05:00
71 changed files with 2604 additions and 1970 deletions

View File

@ -0,0 +1,19 @@
---
description: Never remove or alter subscribe/Stripe configuration
alwaysApply: true
---
# Subscribe / Stripe configuration is off-limits
**Do not use .env.** All config is in `stack.yml`; do not add or rely on `.env` for deploy.
**Do not under any circumstance:**
- Remove, comment out, reorder, or rename the `STRIPE_*` or subscribe-related environment variables in `stack.yml` (the `ss-atlas` service `environment:` block).
- Stash, replace, or overwrite `stack.yml` in a way that drops or changes the Stripe/subscribe env vars.
- Add logic that loads config from `.env` or clears these values at deploy or runtime.
**Required subscribe-related vars in `stack.yml` for `ss-atlas`:**
`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID`, `STRIPE_PRICE_ID_FREE`, `STRIPE_PRICE_ID_YEAR`, `STRIPE_PRICE_ID_MONTH_100`, `STRIPE_PRICE_ID_MONTH_200`, `STRIPE_PAYMENT_LINK`, `FREE_TIER_LIMIT`, `YEAR_TIER_LIMIT`, `MAX_SIGNUPS`.
**If editing `stack.yml` or deploy flow:** preserve the full `ss-atlas` environment section exactly; only add new vars or change values when the user explicitly asks.

View File

@ -1,5 +1,2 @@
# Authelia stable/done; keep out of context for ss-atlas and other work
authelia-dev-config.yml
docker/mariadb/ docker/mariadb/
docker/redis/ docker/redis/

View File

@ -32,25 +32,6 @@ steps:
from_secret: DOCKER_REGISTRY_USER from_secret: DOCKER_REGISTRY_USER
DOCKER_REGISTRY_PASSWORD: DOCKER_REGISTRY_PASSWORD:
from_secret: DOCKER_REGISTRY_PASSWORD from_secret: DOCKER_REGISTRY_PASSWORD
# Authelia Core Secrets
AUTHENTICATION_BACKEND_LDAP_PASSWORD:
from_secret: AUTHENTICATION_BACKEND_LDAP_PASSWORD
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET:
from_secret: IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
STORAGE_ENCRYPTION_KEY:
from_secret: STORAGE_ENCRYPTION_KEY
SESSION_SECRET:
from_secret: SESSION_SECRET
NOTIFIER_SMTP_PASSWORD:
from_secret: NOTIFIER_SMTP_PASSWORD
# OIDC Secrets
IDENTITY_PROVIDERS_OIDC_HMAC_SECRET:
from_secret: IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY:
from_secret: IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
IDENTITY_PROVIDERS_OIDC_JWKS_KEY:
from_secret: IDENTITY_PROVIDERS_OIDC_JWKS_KEY
# OAuth Client Secrets removed - handled by Docker Swarm secrets at runtime
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
commands: commands:
@ -73,25 +54,6 @@ steps:
from_secret: REGISTRY_USER from_secret: REGISTRY_USER
REGISTRY_PASSWORD: REGISTRY_PASSWORD:
from_secret: REGISTRY_PASSWORD from_secret: REGISTRY_PASSWORD
# Authelia Core Secrets
AUTHENTICATION_BACKEND_LDAP_PASSWORD:
from_secret: AUTHENTICATION_BACKEND_LDAP_PASSWORD
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET:
from_secret: IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
STORAGE_ENCRYPTION_KEY:
from_secret: STORAGE_ENCRYPTION_KEY
SESSION_SECRET:
from_secret: SESSION_SECRET
NOTIFIER_SMTP_PASSWORD:
from_secret: NOTIFIER_SMTP_PASSWORD
# OIDC Secrets
IDENTITY_PROVIDERS_OIDC_HMAC_SECRET:
from_secret: IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY:
from_secret: IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
IDENTITY_PROVIDERS_OIDC_JWKS_KEY:
from_secret: IDENTITY_PROVIDERS_OIDC_JWKS_KEY
# OAuth Client Secrets removed - handled by Docker Swarm secrets at runtime
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
commands: commands:

269
README.md
View File

@ -1,268 +1,13 @@
<!-- build 5 --> # ATLAS
# Authelia with Traefik (ATLAS)
## Authentication Traffic LDAP Application Security
A comprehensive, production-ready authentication solution using Authelia with Traefik reverse proxy, featuring automated CI/CD, comprehensive testing, and robust secrets management. ATLAS provisions and manages customer workspaces behind Traefik with Authentik-backed identity.
## 🌟 Features ## Deploy
- **🔐 Complete Authentication Stack**: Authelia + LLDAP + MariaDB + Redis Use the root ship script to build the latest local `ss-atlas` image, push it, and deploy the production stack:
- **🚀 Production-Ready Deployment**: Docker Swarm with Traefik integration
- **🧪 Comprehensive Testing**: Automated pre-commit tests and CI/CD validation
- **🔑 Robust Secrets Management**: Automated generation and rotation capabilities
- **⚡ Development Environment**: Isolated dev setup with hot-reload capabilities
- **🔄 OIDC Integration**: Full OpenID Connect support for client applications
- **📊 Health Monitoring**: Built-in health checks and monitoring endpoints
## 🚀 Quick Start ```sh
./ship.sh
### Prerequisites
- Docker and Docker Compose
- OpenSSL (for secrets generation)
- Git with pre-commit hooks support
### Development Setup
1. **Clone the repository**:
```bash
git clone <repository-url>
cd authelia
```
2. **Start development environment**:
```bash
docker compose -f docker-compose.dev.yml up -d
```
3. **Access services**:
- **Authelia**: http://localhost:9091
- **LLDAP Admin**: http://localhost:17170
- Username: `admin`
- Password: `/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=`
4. **Run tests**:
```bash
./tests/precommit.sh
```
## 🔑 Secrets Management
### Initial Setup
Generate production secrets (⚠️ **Use with extreme caution**):
```bash
./generate-secrets.sh
``` ```
**CRITICAL**: This script will: The active production stack is defined in `stack.production.yml`. Legacy identity artifacts are preserved under `archives/`.
- Invalidate all existing sessions and tokens
- Require updating all 12 secrets in Woodpecker CI vault
- Potentially require recreating database volumes
- Cause service downtime until deployment completes
### CI/CD Vault Management
For comprehensive CI/CD vault setup and secret management:
**📖 [CI/CD Vault Setup Guide](docs/CI_CD_VAULT_SETUP.md)**
### Required Secrets (12 total)
#### Core Secrets (5)
- `AUTHENTICATION_BACKEND_LDAP_PASSWORD` - LDAP authentication backend password
- `IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET` - JWT secret for password reset tokens
- `STORAGE_ENCRYPTION_KEY` - Database encryption key
- `SESSION_SECRET` - Session encryption secret
- `NOTIFIER_SMTP_PASSWORD` - SMTP email notifications password
#### OIDC Secrets (3)
- `IDENTITY_PROVIDERS_OIDC_HMAC_SECRET` - OIDC HMAC signing secret
- `IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY` - OIDC token signing private key (RSA)
- `IDENTITY_PROVIDERS_OIDC_JWKS_KEY` - OIDC JWKS validation key (RSA)
#### Client Secrets (4)
- `CLIENT_SECRET_HEADSCALE` - Headscale VPN OIDC client secret
- `CLIENT_SECRET_HEADADMIN` - Headscale admin panel OIDC client secret
- `CLIENT_SECRET_PORTAINER` - Portainer OAuth client secret
- `CLIENT_SECRET_GITEA` - Gitea OAuth client secret
## 🧪 Testing
### Automated Testing
The project includes comprehensive testing:
- **Pre-commit hooks**: `./tests/precommit.sh`
- **Authentication tests**: `./tests/precommit-auth.sh`
- **CI/CD pipeline**: Automated testing on every push
### Test Coverage
- ✅ Authelia health endpoints
- ✅ Web interface accessibility
- ✅ API endpoint validation
- ✅ Container health status
- ✅ LLDAP integration
- ✅ Service interconnectivity
## 🚀 Deployment
### CI/CD Pipeline
Automated deployment through Woodpecker CI:
1. **Build & Test**: Comprehensive testing on every commit
2. **Build Images**: Multi-stage Docker builds for production
3. **Secret Management**: Automatic Docker secrets recreation
4. **Deploy**: Zero-downtime deployment to Docker Swarm
5. **Verification**: Post-deployment health checks
### Manual Deployment
```bash
# Push changes to trigger CI/CD
git add .
git commit -m "your changes"
git push
# Monitor deployment
ssh macmini7 'docker service logs authelia_authelia --follow'
```
## 🔧 Configuration
### Development vs Production
- **Development**: Uses local secrets in `docker-compose.dev.yml`
- **Production**: Uses Docker Swarm secrets from CI/CD vault
### Environment Variables
Key environment variables for customization:
- `X_AUTHELIA_SITE_NAME` - Site display name
- `X_AUTHELIA_EMAIL` - Notification email address
- `TRAEFIK_DOMAIN` - Base domain for services
## 🔗 OAuth/OIDC Integration
For advanced OAuth/OIDC setup with services like Portainer and Gitea, see the comprehensive guide:
**📖 [OAuth Setup Guide](docs/OAUTH_SETUP.md)**
This includes:
- OAuth client configuration for Portainer and Gitea
- Client secret generation and management
- CI/CD vault setup instructions
- Step-by-step authentication flow setup
### Quick OAuth Setup
```bash
# Generate OAuth client secrets
./scripts/generate-oauth-secrets.sh
# Follow the instructions to update your CI/CD vault
# Then configure OAuth in your services
```
## 📱 Client Integration Examples
### OAuth Integration (Recommended)
Use OAuth for better user experience and native service integration:
```yaml
# Portainer with OAuth - no Traefik middleware needed
labels:
traefik.enable: "true"
traefik.http.routers.portainer.rule: "Host(`portainer.a250.ca`)"
# OAuth configured in Portainer admin panel
```
### Traefik Middleware Protection
Use Authelia middleware for services without OAuth support:
```yaml
labels:
traefik.enable: "true"
traefik.http.routers.myapp.rule: "Host(`myapp.a250.ca`)"
traefik.http.routers.myapp.middlewares: "authelia_authelia@docker"
traefik.http.services.myapp.loadbalancer.server.port: "8080"
```
### Headscale VPN Integration
```yaml
labels:
traefik.enable: "true"
traefik.http.routers.headscale.rule: "Host(`headscale.a250.ca`)"
traefik.http.routers.headscale.entrypoints: "websecure"
traefik.http.routers.headscale.tls.certresolver: "letsencryptresolver"
traefik.http.services.headscale.loadbalancer.server.port: "8080"
```
## 🔍 Monitoring & Troubleshooting
### Health Checks
- **Authelia**: `http://localhost:9091/api/health`
- **Service Status**: `docker service ls`
- **Logs**: `docker service logs authelia_authelia`
### Common Issues
1. **Service won't start**: Check secrets configuration
2. **Authentication fails**: Verify LLDAP connectivity
3. **OIDC issues**: Check RSA key format in JWKS configuration
## 🛠️ Development Workflow
1. **Make changes** to configuration or code
2. **Test locally**: `./tests/precommit.sh`
3. **Commit changes**: Git pre-commit hooks run automatically
4. **Push to repository**: Triggers CI/CD pipeline
5. **Monitor deployment**: Check service health in production
## 📋 Requirements
### Core Infrastructure
- **Docker & Docker Compose**: Container orchestration
- **Traefik**: Reverse proxy and load balancer
- **Authelia**: Authentication and authorization server
- **LLDAP**: Lightweight LDAP server for user management
- **MariaDB**: Database backend
- **Redis**: Session storage and caching
### Development Tools
- **Woodpecker CI**: Continuous integration and deployment
- **Git**: Version control with pre-commit hooks
- **OpenSSL**: Cryptographic operations and secrets generation
## 🔐 Security Considerations
- **Secrets Rotation**: Use `./generate-secrets.sh` for periodic rotation
- **Database Encryption**: All sensitive data encrypted at rest
- **TLS Everywhere**: HTTPS/TLS for all client communications
- **Session Security**: Secure session management with Redis
- **OIDC Standards**: Industry-standard OpenID Connect implementation
## 📖 Documentation
For comprehensive guides and setup instructions:
**📁 [Documentation Directory](docs/README.md)**
Available guides:
- **OAuth/OIDC Setup**: Complete OAuth integration guide
- **CI/CD Vault Setup**: Secret management and vault configuration
- **Troubleshooting**: Common issues and solutions
## 📞 Support & Contributing
### Reporting Issues
- Create detailed bug reports with logs and steps to reproduce
- Include environment details and configuration (without secrets!)
### Contributing
1. Fork the repository
2. Create a feature branch
3. Add tests for new functionality
4. Ensure all tests pass
5. Submit a pull request
## 🙏 Acknowledgments
This project leverages several excellent open-source projects:
- **[Authelia](https://www.authelia.com/)** - Authentication and authorization server
- **[Traefik](https://traefik.io/)** - Cloud-native reverse proxy
- **[LLDAP](https://github.com/nitnelave/lldap)** - Lightweight LDAP implementation
- **[Woodpecker CI](https://woodpecker-ci.org/)** - Continuous integration platform
---
**⚠️ Important**: Always keep `secrets.md` secure and never commit it to version control!

View File

@ -0,0 +1,19 @@
---
description: Never remove /success or other Stripe/auth bypass routes from Authelia
alwaysApply: true
---
# Authelia bypass routes must not be reverted
**Recurring issue:** After Stripe checkout, users are sent to `https://bc.a250.ca/success?session_id=...`. If `/success` is **not** in Authelia's **bypass** list, they get sent to login instead of the success page and provisioning breaks.
**Do not:**
- Remove `/success` from the bypass `resources` in `stack.yml` (the Authelia command that writes `configuration.acl.yml`).
- Remove or merge the bypass block that contains: `^/$$`, `^/subscribe/?$$`, `^/success(/|\\?.*)?$$`, `^/webhook/stripe/?$$`, `^/resend-reset/?$$`, `^/health/?$$`, `^/version/?$$`, `^/admin/delete-user/?$$`.
- Change the regex for success to something that no longer matches `/success?session_id=...`.
**Required bypass resources for bc.a250.ca (second bypass block):**
`/`, `/subscribe`, `/success` (with optional query), `/webhook/stripe`, `/resend-reset`, `/health`, `/version`, `/admin/delete-user`.
**If editing `stack.yml` Authelia section:** keep the entire bypass block and all of these resources; only add new paths when the user explicitly asks.

267
archives/README.md Normal file
View File

@ -0,0 +1,267 @@
<!-- build 5 -->
# Authelia with Traefik (ATLAS)
## Authentication Traffic LDAP Application Security
A comprehensive, production-ready authentication solution using Authelia with Traefik reverse proxy, featuring automated CI/CD, comprehensive testing, and robust secrets management.
## 🌟 Features
- **🔐 Complete Authentication Stack**: Authelia + LLDAP + MariaDB + Redis
- **🚀 Production-Ready Deployment**: Docker Swarm with Traefik integration
- **🧪 Comprehensive Testing**: Automated pre-commit tests and CI/CD validation
- **🔑 Robust Secrets Management**: Automated generation and rotation capabilities
- **⚡ Development Environment**: Isolated dev setup with hot-reload capabilities
- **🔄 OIDC Integration**: Full OpenID Connect support for client applications
- **📊 Health Monitoring**: Built-in health checks and monitoring endpoints
## 🚀 Quick Start
### Prerequisites
- Docker and Docker Compose
- OpenSSL (for secrets generation)
- Git with pre-commit hooks support
### Development Setup
1. **Clone the repository**:
```bash
git clone <repository-url>
cd authelia
```
2. **Start development environment**:
```bash
docker compose -f docker-compose.dev.yml up -d
```
3. **Access services**:
- **Authelia**: http://localhost:9091
- **LLDAP Admin**: http://localhost:17170
- Username: `admin`
- Password: `/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=`
4. **Run tests**:
```bash
./tests/precommit.sh
```
## 🔑 Secrets Management
### Initial Setup
Generate production secrets (⚠️ **Use with extreme caution**):
```bash
./generate-secrets.sh
```
**CRITICAL**: This script will:
- Invalidate all existing sessions and tokens
- Require updating all 12 secrets in Woodpecker CI vault
- Potentially require recreating database volumes
- Cause service downtime until deployment completes
### CI/CD Vault Management
For comprehensive CI/CD vault setup and secret management:
**📖 [CI/CD Vault Setup Guide](docs/CI_CD_VAULT_SETUP.md)**
### Required Secrets (12 total)
#### Core Secrets (5)
- `AUTHENTICATION_BACKEND_LDAP_PASSWORD` - LDAP authentication backend password
- `IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET` - JWT secret for password reset tokens
- `STORAGE_ENCRYPTION_KEY` - Database encryption key
- `SESSION_SECRET` - Session encryption secret
- `NOTIFIER_SMTP_PASSWORD` - SMTP email notifications password
#### OIDC Secrets (3)
- `IDENTITY_PROVIDERS_OIDC_HMAC_SECRET` - OIDC HMAC signing secret
- `IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY` - OIDC token signing private key (RSA)
- `IDENTITY_PROVIDERS_OIDC_JWKS_KEY` - OIDC JWKS validation key (RSA)
#### Client Secrets (3)
- `CLIENT_SECRET_HEADSCALE` - Headscale VPN OIDC client secret
- `CLIENT_SECRET_HEADADMIN` - Headscale admin panel OIDC client secret
- `CLIENT_SECRET_PORTAINER` - Portainer OAuth client secret
## 🧪 Testing
### Automated Testing
The project includes comprehensive testing:
- **Pre-commit hooks**: `./tests/precommit.sh`
- **Authentication tests**: `./tests/precommit-auth.sh`
- **CI/CD pipeline**: Automated testing on every push
### Test Coverage
- ✅ Authelia health endpoints
- ✅ Web interface accessibility
- ✅ API endpoint validation
- ✅ Container health status
- ✅ LLDAP integration
- ✅ Service interconnectivity
## 🚀 Deployment
### CI/CD Pipeline
Automated deployment through Woodpecker CI:
1. **Build & Test**: Comprehensive testing on every commit
2. **Build Images**: Multi-stage Docker builds for production
3. **Secret Management**: Automatic Docker secrets recreation
4. **Deploy**: Zero-downtime deployment to Docker Swarm
5. **Verification**: Post-deployment health checks
### Manual Deployment
```bash
# Push changes to trigger CI/CD
git add .
git commit -m "your changes"
git push
# Monitor deployment
ssh macmini7 'docker service logs authelia_authelia --follow'
```
## 🔧 Configuration
### Development vs Production
- **Development**: Uses local secrets in `docker-compose.dev.yml`
- **Production**: Uses Docker Swarm secrets from CI/CD vault
### Environment Variables
Key environment variables for customization:
- `X_AUTHELIA_SITE_NAME` - Site display name
- `X_AUTHELIA_EMAIL` - Notification email address
- `TRAEFIK_DOMAIN` - Base domain for services
## 🔗 OAuth/OIDC Integration
For advanced OAuth/OIDC setup with services like Portainer, see the comprehensive guide:
**📖 [OAuth Setup Guide](docs/OAUTH_SETUP.md)**
This includes:
- OAuth client configuration for Portainer
- Client secret generation and management
- CI/CD vault setup instructions
- Step-by-step authentication flow setup
### Quick OAuth Setup
```bash
# Generate OAuth client secrets
./scripts/generate-oauth-secrets.sh
# Follow the instructions to update your CI/CD vault
# Then configure OAuth in your services
```
## 📱 Client Integration Examples
### OAuth Integration (Recommended)
Use OAuth for better user experience and native service integration:
```yaml
# Portainer with OAuth - no Traefik middleware needed
labels:
traefik.enable: "true"
traefik.http.routers.portainer.rule: "Host(`portainer.a250.ca`)"
# OAuth configured in Portainer admin panel
```
### Traefik Middleware Protection
Use Authelia middleware for services without OAuth support:
```yaml
labels:
traefik.enable: "true"
traefik.http.routers.myapp.rule: "Host(`myapp.a250.ca`)"
traefik.http.routers.myapp.middlewares: "authelia_authelia@docker"
traefik.http.services.myapp.loadbalancer.server.port: "8080"
```
### Headscale VPN Integration
```yaml
labels:
traefik.enable: "true"
traefik.http.routers.headscale.rule: "Host(`headscale.a250.ca`)"
traefik.http.routers.headscale.entrypoints: "websecure"
traefik.http.routers.headscale.tls.certresolver: "letsencryptresolver"
traefik.http.services.headscale.loadbalancer.server.port: "8080"
```
## 🔍 Monitoring & Troubleshooting
### Health Checks
- **Authelia**: `http://localhost:9091/api/health`
- **Service Status**: `docker service ls`
- **Logs**: `docker service logs authelia_authelia`
### Common Issues
1. **Service won't start**: Check secrets configuration
2. **Authentication fails**: Verify LLDAP connectivity
3. **OIDC issues**: Check RSA key format in JWKS configuration
## 🛠️ Development Workflow
1. **Make changes** to configuration or code
2. **Test locally**: `./tests/precommit.sh`
3. **Commit changes**: Git pre-commit hooks run automatically
4. **Push to repository**: Triggers CI/CD pipeline
5. **Monitor deployment**: Check service health in production
## 📋 Requirements
### Core Infrastructure
- **Docker & Docker Compose**: Container orchestration
- **Traefik**: Reverse proxy and load balancer
- **Authelia**: Authentication and authorization server
- **LLDAP**: Lightweight LDAP server for user management
- **MariaDB**: Database backend
- **Redis**: Session storage and caching
### Development Tools
- **Woodpecker CI**: Continuous integration and deployment
- **Git**: Version control with pre-commit hooks
- **OpenSSL**: Cryptographic operations and secrets generation
## 🔐 Security Considerations
- **Secrets Rotation**: Use `./generate-secrets.sh` for periodic rotation
- **Database Encryption**: All sensitive data encrypted at rest
- **TLS Everywhere**: HTTPS/TLS for all client communications
- **Session Security**: Secure session management with Redis
- **OIDC Standards**: Industry-standard OpenID Connect implementation
## 📖 Documentation
For comprehensive guides and setup instructions:
**📁 [Documentation Directory](docs/README.md)**
Available guides:
- **OAuth/OIDC Setup**: Complete OAuth integration guide
- **CI/CD Vault Setup**: Secret management and vault configuration
- **Troubleshooting**: Common issues and solutions
## 📞 Support & Contributing
### Reporting Issues
- Create detailed bug reports with logs and steps to reproduce
- Include environment details and configuration (without secrets!)
### Contributing
1. Fork the repository
2. Create a feature branch
3. Add tests for new functionality
4. Ensure all tests pass
5. Submit a pull request
## 🙏 Acknowledgments
This project leverages several excellent open-source projects:
- **[Authelia](https://www.authelia.com/)** - Authentication and authorization server
- **[Traefik](https://traefik.io/)** - Cloud-native reverse proxy
- **[LLDAP](https://github.com/nitnelave/lldap)** - Lightweight LDAP implementation
- **[Woodpecker CI](https://woodpecker-ci.org/)** - Continuous integration platform
---
**⚠️ Important**: Always keep `secrets.md` secure and never commit it to version control!

View File

@ -0,0 +1,3 @@
{
"Home": "Go to App"
}

View File

@ -2,6 +2,7 @@ theme: grey
server: server:
address: tcp://:9091 address: tcp://:9091
asset_path: /config/assets
buffers: buffers:
read: 8192 read: 8192
write: 8192 write: 8192

View File

@ -4,9 +4,9 @@
<body> <body>
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
<p>Hi {{ .DisplayName }},</p> <p>Hi {{ .DisplayName }},</p>
<p>You requested to set or reset your password for your <a href="https://bc.a250.ca">a250.ca</a> workspace.</p> <p>You requested to set or reset your password for your <a href="https://app.a250.ca">a250.ca</a> workspace.</p>
<p>Click the link below to choose your password. You will also need to enable two-factor authentication or a passkey.</p> <p>Click the link below to choose your password. You will also need to enable two-factor authentication or a passkey.</p>
{{ $parts := splitList "token=" .LinkURL }}<p><a href="https://bc.a250.ca/login/reset-password/step2?token={{ index $parts 1 }}">{{ .LinkText }}</a></p> {{ $parts := splitList "token=" .LinkURL }}<p><a href="https://app.a250.ca/login/reset-password/step2?token={{ index $parts 1 }}">{{ .LinkText }}</a></p>
<p>If you did not request this, you can safely ignore this email &mdash; no changes will be made.</p> <p>If you did not request this, you can safely ignore this email &mdash; no changes will be made.</p>
<p style="color:#888;font-size:0.85em;">Requested from {{ .RemoteIP }}.</p> <p style="color:#888;font-size:0.85em;">Requested from {{ .RemoteIP }}.</p>
</body> </body>

View File

@ -2,11 +2,11 @@
Hi {{ .DisplayName }}, Hi {{ .DisplayName }},
You requested to set or reset your password for your a250.ca workspace (https://bc.a250.ca). You requested to set or reset your password for your a250.ca workspace (https://app.a250.ca).
Use the link below to choose your password. You will also need to enable two-factor authentication or a passkey. Use the link below to choose your password. You will also need to enable two-factor authentication or a passkey.
{{ $parts := splitList "token=" .LinkURL }}https://bc.a250.ca/login/reset-password/step2?token={{ index $parts 1 }} {{ $parts := splitList "token=" .LinkURL }}https://app.a250.ca/login/reset-password/step2?token={{ index $parts 1 }}
If you did not request this, you can safely ignore this email — no changes will be made. If you did not request this, you can safely ignore this email — no changes will be made.

View File

@ -0,0 +1,42 @@
package handlers
import (
"encoding/json"
"net"
"net/http"
"strings"
)
func (a *App) handleResendReset(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"message": "Use the sign-in button to continue with your social account.",
})
}
func respondResendError(w http.ResponseWriter, code int, msg string, retryAfter int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
body := map[string]any{"ok": false, "error": msg}
if retryAfter > 0 {
body["retry_after_seconds"] = retryAfter
}
json.NewEncoder(w).Encode(body)
}
func clientIP(r *http.Request) string {
if s := r.Header.Get("X-Forwarded-For"); s != "" {
if idx := strings.Index(s, ","); idx > 0 {
return strings.TrimSpace(s[:idx])
}
return strings.TrimSpace(s)
}
if s := r.Header.Get("X-Real-IP"); s != "" {
return s
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}

View File

@ -271,6 +271,31 @@ func (c *Client) CountCustomers() (int, error) {
return n, nil return n, nil
} }
// DeleteUser removes the user from the customers group (if present) and deletes the LDAP entry.
// Call RemoveStack for the customer stack and prune volumes separately if needed.
func (c *Client) DeleteUser(username string) error {
_ = c.RemoveFromGroup(username, "customers")
conn, err := c.connect()
if err != nil {
return err
}
defer conn.Close()
exists, err := c.userExists(conn, username)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("user %s not found", username)
}
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
delReq := goldap.NewDelRequest(userDN, nil)
if err := conn.Del(delReq); err != nil {
return fmt.Errorf("ldap delete user %s: %w", username, err)
}
log.Printf("deleted ldap user %s", username)
return nil
}
func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) { func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) {
searchReq := goldap.NewSearchRequest( searchReq := goldap.NewSearchRequest(
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN), fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN),

View File

@ -28,7 +28,6 @@ Your Woodpecker CI vault must contain **12 total secrets** for proper Authelia d
| `CLIENT_SECRET_HEADSCALE` | Headscale VPN OIDC client | `./generate-secrets.sh` | | `CLIENT_SECRET_HEADSCALE` | Headscale VPN OIDC client | `./generate-secrets.sh` |
| `CLIENT_SECRET_HEADADMIN` | Headscale admin OIDC client | `./generate-secrets.sh` | | `CLIENT_SECRET_HEADADMIN` | Headscale admin OIDC client | `./generate-secrets.sh` |
| `CLIENT_SECRET_PORTAINER` | Portainer OAuth client | `./scripts/generate-oauth-secrets.sh` | | `CLIENT_SECRET_PORTAINER` | Portainer OAuth client | `./scripts/generate-oauth-secrets.sh` |
| `CLIENT_SECRET_GITEA` | Gitea OAuth client | `./scripts/generate-oauth-secrets.sh` |
## 🚀 Setup Process ## 🚀 Setup Process
@ -64,7 +63,6 @@ export WOODPECKER_TOKEN=your-api-token
# Update all secrets (example commands) # Update all secrets (example commands)
woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)" woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)"
woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --value "$(cat secrets/clients/gitea-secret.txt)"
``` ```
## 🔄 Secret Rotation ## 🔄 Secret Rotation
@ -85,7 +83,7 @@ woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --val
# Regenerate OAuth client secrets only # Regenerate OAuth client secrets only
./scripts/generate-oauth-secrets.sh ./scripts/generate-oauth-secrets.sh
# Update CLIENT_SECRET_PORTAINER and CLIENT_SECRET_GITEA in vault # Update CLIENT_SECRET_PORTAINER in vault
# Deploy when convenient # Deploy when convenient
``` ```

View File

@ -1,6 +1,6 @@
# OAuth/OIDC Client Setup Guide # OAuth/OIDC Client Setup Guide
This guide covers setting up OAuth/OIDC authentication for services like Portainer and Gitea using Authelia as the identity provider. This guide covers setting up OAuth/OIDC authentication for services like Portainer using Authelia as the identity provider.
## 🔧 Overview ## 🔧 Overview
@ -27,10 +27,6 @@ Add these to your Woodpecker CI vault:
- **Variable**: `CLIENT_SECRET_PORTAINER` - **Variable**: `CLIENT_SECRET_PORTAINER`
- **Value**: Generated from `secrets/clients/portainer-secret.txt` - **Value**: Generated from `secrets/clients/portainer-secret.txt`
#### Gitea OAuth
- **Variable**: `CLIENT_SECRET_GITEA`
- **Value**: Generated from `secrets/clients/gitea-secret.txt`
## 📱 Client Configurations ## 📱 Client Configurations
### Portainer OAuth Setup ### Portainer OAuth Setup
@ -75,39 +71,6 @@ Once OAuth is working, remove middleware protection:
# traefik.http.routers.portainer.middlewares: authelia_authelia # traefik.http.routers.portainer.middlewares: authelia_authelia
``` ```
### Gitea OAuth Setup
#### 1. Authelia Configuration
Already configured in `docker/authelia/config/configuration.oidc.clients.yml`:
```yaml
- client_id: gitea
client_name: Gitea
client_secret: {{ secret "/run/secrets/CLIENT_SECRET_GITEA" }}
public: false
authorization_policy: one_factor
consent_mode: implicit
scopes:
- openid
- email
- profile
- groups
redirect_uris:
- https://git.{{ env "TRAEFIK_DOMAIN" }}/user/oauth2/authelia/callback
userinfo_signed_response_alg: none
```
#### 2. Gitea OAuth Settings
Configure in Gitea → Site Administration → Authentication Sources:
- **Authentication Type**: OAuth2
- **Authentication Name**: `Authelia`
- **OAuth2 Provider**: OpenID Connect
- **Client ID**: `gitea`
- **Client Secret**: `<from CI vault>`
- **OpenID Connect Auto Discovery URL**: `https://login.a250.ca/.well-known/openid_configuration`
- **Icon URL**: `https://login.a250.ca/static/media/logo.png` (optional)
## 🔄 Deployment Process ## 🔄 Deployment Process
### 1. Generate Secrets ### 1. Generate Secrets
@ -118,7 +81,6 @@ Configure in Gitea → Site Administration → Authentication Sources:
### 2. Update CI/CD Vault ### 2. Update CI/CD Vault
Add the generated secrets to your Woodpecker CI vault: Add the generated secrets to your Woodpecker CI vault:
- `CLIENT_SECRET_PORTAINER` - `CLIENT_SECRET_PORTAINER`
- `CLIENT_SECRET_GITEA`
### 3. Deploy Authelia ### 3. Deploy Authelia
Push changes to trigger CI/CD deployment with new OAuth clients. Push changes to trigger CI/CD deployment with new OAuth clients.

View File

@ -5,7 +5,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
## 📚 Available Guides ## 📚 Available Guides
### 🔧 Setup & Configuration ### 🔧 Setup & Configuration
- **[OAuth/OIDC Setup Guide](OAUTH_SETUP.md)** - Complete OAuth integration for Portainer, Gitea, and other services - **[OAuth/OIDC Setup Guide](OAUTH_SETUP.md)** - Complete OAuth integration for Portainer and other services
- **[CI/CD Vault Setup](CI_CD_VAULT_SETUP.md)** - Secret management and Woodpecker CI vault configuration - **[CI/CD Vault Setup](CI_CD_VAULT_SETUP.md)** - Secret management and Woodpecker CI vault configuration
### 🚀 Getting Started ### 🚀 Getting Started
@ -18,7 +18,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
2. **OAuth Integration** 2. **OAuth Integration**
- Generate OAuth client secrets with `./scripts/generate-oauth-secrets.sh` - Generate OAuth client secrets with `./scripts/generate-oauth-secrets.sh`
- Follow [OAuth Setup Guide](OAUTH_SETUP.md) for service configuration - Follow [OAuth Setup Guide](OAUTH_SETUP.md) for service configuration
- Configure individual services (Portainer, Gitea) with OAuth - Configure individual services (e.g. Portainer) with OAuth
3. **Production Deployment** 3. **Production Deployment**
- Commit changes to trigger CI/CD pipeline - Commit changes to trigger CI/CD pipeline
@ -55,7 +55,7 @@ docker compose -f docker-compose.dev.yml up -d
### Required Secrets (12 Total) ### Required Secrets (12 Total)
- **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP - **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP
- **OIDC Secrets (3)**: HMAC, private key, JWKS key - **OIDC Secrets (3)**: HMAC, private key, JWKS key
- **Client Secrets (4)**: Headscale (2), Portainer, Gitea - **Client Secrets (3)**: Headscale (2), Portainer
## 🔍 Troubleshooting ## 🔍 Troubleshooting

View File

@ -4,7 +4,7 @@ set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
[ -f .env ] && set -a && . .env && set +a # Config is in stack.yml; do not use .env
if [ -n "$(git status --porcelain)" ]; then if [ -n "$(git status --porcelain)" ]; then
echo "ERROR: Working tree is dirty. Commit your changes before deploying." >&2 echo "ERROR: Working tree is dirty. Commit your changes before deploying." >&2

View File

@ -106,11 +106,6 @@ Add these secrets to your Woodpecker CI vault:
- **Secret File**: `secrets/clients/portainer-secret.txt` - **Secret File**: `secrets/clients/portainer-secret.txt`
- **Value**: (copy content from the file above) - **Value**: (copy content from the file above)
### Gitea OAuth
- **Variable Name**: `CLIENT_SECRET_GITEA`
- **Secret File**: `secrets/clients/gitea-secret.txt`
- **Value**: (copy content from the file above)
## Important Notes ## Important Notes
1. **Never commit these files** - they are automatically gitignored 1. **Never commit these files** - they are automatically gitignored
@ -124,9 +119,6 @@ If using Woodpecker CLI:
```bash ```bash
# Update Portainer secret # Update Portainer secret
woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)" woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)"
# Update Gitea secret
woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --value "$(cat secrets/clients/gitea-secret.txt)"
``` ```
## Verification ## Verification
@ -149,17 +141,15 @@ print_summary() {
echo "${YELLOW}📁 Generated Files:${NC}" echo "${YELLOW}📁 Generated Files:${NC}"
echo " • secrets/oauth-secrets.env" echo " • secrets/oauth-secrets.env"
echo " • secrets/clients/portainer-secret.txt" echo " • secrets/clients/portainer-secret.txt"
echo " • secrets/clients/gitea-secret.txt"
echo " • secrets/VAULT_SECRETS.md" echo " • secrets/VAULT_SECRETS.md"
echo echo
echo "${YELLOW}🔑 Required CI/CD Vault Updates:${NC}" echo "${YELLOW}🔑 Required CI/CD Vault Updates:${NC}"
echo " • CLIENT_SECRET_PORTAINER" echo " • CLIENT_SECRET_PORTAINER"
echo " • CLIENT_SECRET_GITEA"
echo echo
echo "${RED}⚠️ NEXT STEPS:${NC}" echo "${RED}⚠️ NEXT STEPS:${NC}"
echo " 1. Update your CI/CD vault with new secrets" echo " 1. Update your CI/CD vault with new secrets"
echo " 2. Deploy Authelia to use new client configurations" echo " 2. Deploy Authelia to use new client configurations"
echo " 3. Configure OAuth in Portainer and Gitea admin panels" echo " 3. Configure OAuth in Portainer admin panel"
echo " 4. Test authentication flows" echo " 4. Test authentication flows"
echo echo
echo "${BLUE}📖 Full setup guide: docs/OAUTH_SETUP.md${NC}" echo "${BLUE}📖 Full setup guide: docs/OAUTH_SETUP.md${NC}"
@ -195,7 +185,6 @@ main() {
# Generate client secrets # Generate client secrets
generate_client_secret "portainer" "portainer-secret.txt" generate_client_secret "portainer" "portainer-secret.txt"
generate_client_secret "gitea" "gitea-secret.txt"
create_vault_instructions create_vault_instructions
print_summary print_summary

Binary file not shown.

View File

@ -1,18 +1,9 @@
version: '3.8'
services: services:
mariadb: ss-atlas:
build: build:
context: ./docker/mariadb/ context: ./docker/ss-atlas/
dockerfile: Dockerfile.production dockerfile: Dockerfile
image: git.nixc.us/a250/authelia:production-mariadb args:
redis: BUILD_COMMIT: ${BUILD_COMMIT:-unknown}
build: BUILD_TIME: ${BUILD_TIME:-unknown}
context: ./docker/redis/ image: git.nixc.us/a250/ss-atlas:production
dockerfile: Dockerfile.production
image: git.nixc.us/a250/authelia:production-redis
authelia:
build:
context: ./docker/authelia/
dockerfile: Dockerfile.production
image: git.nixc.us/a250/authelia:production-authelia

View File

@ -1,10 +1,4 @@
services: services:
authelia:
build:
context: ./docker/authelia/
dockerfile: Dockerfile
image: git.nixc.us/a250/authelia:dev-authelia
ss-atlas: ss-atlas:
build: build:
context: ./docker/ss-atlas/ context: ./docker/ss-atlas/

View File

@ -9,9 +9,9 @@ import (
"syscall" "syscall"
"time" "time"
"git.nixc.us/a250/ss-atlas/internal/accounts"
"git.nixc.us/a250/ss-atlas/internal/config" "git.nixc.us/a250/ss-atlas/internal/config"
"git.nixc.us/a250/ss-atlas/internal/handlers" "git.nixc.us/a250/ss-atlas/internal/handlers"
"git.nixc.us/a250/ss-atlas/internal/ldap"
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe" ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
"git.nixc.us/a250/ss-atlas/internal/swarm" "git.nixc.us/a250/ss-atlas/internal/swarm"
"git.nixc.us/a250/ss-atlas/internal/version" "git.nixc.us/a250/ss-atlas/internal/version"
@ -22,10 +22,14 @@ func main() {
cfg := config.Load() cfg := config.Load()
stripeClient := ssstripe.New(cfg) stripeClient := ssstripe.New(cfg)
ldapClient := ldap.New(cfg) accountStore, err := accounts.New(context.Background(), cfg.DatabaseURL)
if err != nil {
log.Fatalf("account store error: %v", err)
}
defer accountStore.Close()
swarmClient := swarm.New(cfg) swarmClient := swarm.New(cfg)
router := handlers.NewRouter(cfg, stripeClient, ldapClient, swarmClient) router := handlers.NewRouter(cfg, stripeClient, accountStore, swarmClient)
srv := &http.Server{ srv := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,

View File

@ -4,13 +4,8 @@ go 1.23
require ( require (
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi/v5 v5.2.1
github.com/go-ldap/ldap/v3 v3.4.10 github.com/lib/pq v1.12.3
github.com/stripe/stripe-go/v84 v84.4.0 github.com/stripe/stripe-go/v84 v84.4.0
) )
require ( require github.com/stretchr/testify v1.8.1 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/google/uuid v1.6.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
)

View File

@ -1,119 +1,22 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v84 v84.4.0 h1:JMQMqb+mhW6tns+eYA3G5SZiaoD2ULwN0lZ+kNjWAsY= github.com/stripe/stripe-go/v84 v84.4.0 h1:JMQMqb+mhW6tns+eYA3G5SZiaoD2ULwN0lZ+kNjWAsY=
github.com/stripe/stripe-go/v84 v84.4.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw= github.com/stripe/stripe-go/v84 v84.4.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,44 @@
package accounts
import "time"
type Account struct {
ID int64
PrimaryEmail string
DisplayName string
StripeCustomerID string
SubscriptionStatus string
CreatedAt time.Time
UpdatedAt time.Time
}
type Identity struct {
Provider string
Subject string
Username string
Email string
Name string
Groups string
}
type Instance struct {
ID int64
AccountID int64
Slug string
StackName string
CustomerDomain string
State string
LastDeployedAt *time.Time
}
type CheckoutInput struct {
AccountID int64
Email string
DisplayName string
Phone string
CustomerDomain string
StripeCustomerID string
StripeSubscriptionID string
StripeSessionID string
StripeEventID string
}

View File

@ -0,0 +1,192 @@
package accounts
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
type txQueryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func (s *Store) UpsertCheckout(ctx context.Context, input CheckoutInput) (*Account, *Instance, error) {
email := strings.ToLower(strings.TrimSpace(input.Email))
if input.StripeCustomerID == "" {
return nil, nil, errors.New("Stripe customer id is required")
}
if email == "" && input.AccountID == 0 {
return nil, nil, errors.New("email is required when account id is missing")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, nil, err
}
defer tx.Rollback()
if input.StripeEventID != "" {
if processed, err := eventProcessed(ctx, tx, input.StripeEventID); err != nil {
return nil, nil, err
} else if processed {
acct, inst, loadErr := s.accountAndInstanceByStripeTx(ctx, tx, input.StripeCustomerID)
if loadErr != nil {
return nil, nil, loadErr
}
return acct, inst, tx.Commit()
}
}
var acct *Account
if input.AccountID > 0 {
acct, err = accountByID(ctx, tx, input.AccountID)
}
if acct == nil && (input.AccountID == 0 || errors.Is(err, ErrNotFound)) {
acct, err = accountByStripeCustomerID(ctx, tx, input.StripeCustomerID)
}
if errors.Is(err, ErrNotFound) {
acct, err = upsertCheckoutAccount(ctx, tx, input)
}
if err != nil {
return nil, nil, err
}
if email == "" {
input.Email = acct.PrimaryEmail
email = strings.ToLower(strings.TrimSpace(input.Email))
}
if input.DisplayName == "" {
input.DisplayName = acct.DisplayName
}
if err := updateCheckoutAccount(ctx, tx, acct.ID, input); err != nil {
return nil, nil, err
}
if err := linkIdentity(ctx, tx, acct.ID, "stripe", input.StripeCustomerID, email); err != nil {
return nil, nil, err
}
inst, err := ensureInstance(ctx, tx, acct.ID, email, input.CustomerDomain)
if err != nil {
return nil, nil, err
}
if input.StripeEventID != "" {
if err := insertBillingEvent(ctx, tx, input, "checkout.session.completed"); err != nil {
return nil, nil, err
}
}
return acct, inst, tx.Commit()
}
func (s *Store) AccountByStripeCustomerID(ctx context.Context, customerID string) (*Account, *Instance, error) {
return s.accountAndInstanceByStripeTx(ctx, s.db, customerID)
}
func (s *Store) MarkSubscriptionStatus(ctx context.Context, customerID, status string) error {
res, err := s.db.ExecContext(ctx, `
UPDATE accounts
SET subscription_status = $2, updated_at = now()
WHERE stripe_customer_id = $1
`, customerID, status)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) InstanceByAccountID(ctx context.Context, accountID int64) (*Instance, error) {
return instanceByAccountID(ctx, s.db, accountID)
}
func (s *Store) InstanceBySlug(ctx context.Context, slug string) (*Instance, error) {
return instanceBySlug(ctx, s.db, slug)
}
func (s *Store) UpdateInstanceState(ctx context.Context, stackName, state string, deployed bool) error {
q := `UPDATE instances SET state = $2, updated_at = now()`
args := []any{stackName, state}
if deployed {
q += `, last_deployed_at = now()`
}
q += ` WHERE stack_name = $1`
_, err := s.db.ExecContext(ctx, q, args...)
return err
}
func (s *Store) DeleteAccountByInstanceSlug(ctx context.Context, slug string) (*Instance, error) {
inst, err := instanceBySlug(ctx, s.db, slug)
if err != nil {
return nil, err
}
_, err = s.db.ExecContext(ctx, `DELETE FROM accounts WHERE id = $1`, inst.AccountID)
return inst, err
}
func upsertCheckoutAccount(ctx context.Context, q txQueryer, input CheckoutInput) (*Account, error) {
return scanAccount(q.QueryRowContext(ctx, `
INSERT INTO accounts (primary_email, display_name, phone, stripe_customer_id, subscription_status)
VALUES ($1, $2, $3, $4, 'active')
ON CONFLICT (primary_email) DO UPDATE SET
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), accounts.display_name),
phone = COALESCE(NULLIF(EXCLUDED.phone, ''), accounts.phone),
stripe_customer_id = EXCLUDED.stripe_customer_id,
subscription_status = 'active',
updated_at = now()
RETURNING id, primary_email, display_name, stripe_customer_id, subscription_status, created_at, updated_at
`, strings.ToLower(input.Email), input.DisplayName, input.Phone, input.StripeCustomerID))
}
func updateCheckoutAccount(ctx context.Context, q txQueryer, accountID int64, input CheckoutInput) error {
_, err := q.ExecContext(ctx, `
UPDATE accounts
SET primary_email = COALESCE(NULLIF($2, ''), primary_email),
display_name = COALESCE(NULLIF($3, ''), display_name),
phone = COALESCE(NULLIF($4, ''), phone),
stripe_customer_id = COALESCE(NULLIF($5, ''), stripe_customer_id),
subscription_status = 'active',
updated_at = now()
WHERE id = $1
`, accountID, strings.ToLower(input.Email), input.DisplayName, input.Phone, input.StripeCustomerID)
return err
}
func (s *Store) accountAndInstanceByStripeTx(ctx context.Context, q txQueryer, customerID string) (*Account, *Instance, error) {
acct, err := accountByStripeCustomerID(ctx, q, customerID)
if err != nil {
return nil, nil, err
}
inst, err := instanceByAccountID(ctx, q, acct.ID)
return acct, inst, err
}
func accountByStripeCustomerID(ctx context.Context, q txQueryer, customerID string) (*Account, error) {
return scanAccount(q.QueryRowContext(ctx, `
SELECT id, primary_email, display_name, stripe_customer_id, subscription_status, created_at, updated_at
FROM accounts WHERE stripe_customer_id = $1
`, customerID))
}
func ensureInstance(ctx context.Context, q txQueryer, accountID int64, email, domain string) (*Instance, error) {
if inst, err := instanceByAccountID(ctx, q, accountID); err == nil {
if domain != "" && inst.CustomerDomain != domain {
_, _ = q.ExecContext(ctx, `UPDATE instances SET customer_domain = $2, updated_at = now() WHERE id = $1`, inst.ID, domain)
inst.CustomerDomain = domain
}
return inst, nil
} else if !errors.Is(err, ErrNotFound) {
return nil, err
}
slug := SlugFromEmail(email)
if owner, err := instanceBySlug(ctx, q, slug); err == nil && owner.AccountID != accountID {
slug = fmt.Sprintf("%s-%d", slug, accountID)
}
stackName := "customer-" + slug
return scanInstance(q.QueryRowContext(ctx, `
INSERT INTO instances (account_id, slug, stack_name, customer_domain, state)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING id, account_id, slug, stack_name, customer_domain, state, last_deployed_at
`, accountID, slug, stackName, domain))
}

View File

@ -0,0 +1,125 @@
package accounts
import (
"context"
"database/sql"
"errors"
)
func upsertAccountByEmail(ctx context.Context, q txQueryer, email, displayName string) (*Account, error) {
return scanAccount(q.QueryRowContext(ctx, `
INSERT INTO accounts (primary_email, display_name)
VALUES ($1, $2)
ON CONFLICT (primary_email) DO UPDATE SET
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), accounts.display_name),
updated_at = now()
RETURNING id, primary_email, display_name, stripe_customer_id, subscription_status, created_at, updated_at
`, email, displayName))
}
func updateAccountProfile(ctx context.Context, q txQueryer, accountID int64, email, displayName string) error {
_, err := q.ExecContext(ctx, `
UPDATE accounts
SET primary_email = COALESCE(NULLIF($2, ''), primary_email),
display_name = COALESCE(NULLIF($3, ''), display_name),
updated_at = now()
WHERE id = $1
`, accountID, email, displayName)
return err
}
func linkIdentity(ctx context.Context, q txQueryer, accountID int64, provider, subject, email string) error {
_, err := q.ExecContext(ctx, `
INSERT INTO account_identities (account_id, provider, provider_subject, email_at_login)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, provider_subject) DO UPDATE SET
account_id = EXCLUDED.account_id,
email_at_login = EXCLUDED.email_at_login
`, accountID, provider, subject, email)
return err
}
func accountByIdentity(ctx context.Context, q txQueryer, provider, subject string) (*Account, error) {
return scanAccount(q.QueryRowContext(ctx, `
SELECT a.id, a.primary_email, a.display_name, a.stripe_customer_id,
a.subscription_status, a.created_at, a.updated_at
FROM accounts a
JOIN account_identities i ON i.account_id = a.id
WHERE i.provider = $1 AND i.provider_subject = $2
`, provider, subject))
}
func accountByID(ctx context.Context, q txQueryer, accountID int64) (*Account, error) {
return scanAccount(q.QueryRowContext(ctx, `
SELECT id, primary_email, display_name, stripe_customer_id, subscription_status, created_at, updated_at
FROM accounts WHERE id = $1
`, accountID))
}
func accountByEmail(ctx context.Context, q txQueryer, email string) (*Account, error) {
return scanAccount(q.QueryRowContext(ctx, `
SELECT id, primary_email, display_name, stripe_customer_id, subscription_status, created_at, updated_at
FROM accounts WHERE primary_email = $1
`, email))
}
func instanceByAccountID(ctx context.Context, q txQueryer, accountID int64) (*Instance, error) {
return scanInstance(q.QueryRowContext(ctx, `
SELECT id, account_id, slug, stack_name, customer_domain, state, last_deployed_at
FROM instances WHERE account_id = $1
`, accountID))
}
func instanceBySlug(ctx context.Context, q txQueryer, slug string) (*Instance, error) {
return scanInstance(q.QueryRowContext(ctx, `
SELECT id, account_id, slug, stack_name, customer_domain, state, last_deployed_at
FROM instances WHERE slug = $1
`, slug))
}
func eventProcessed(ctx context.Context, q txQueryer, eventID string) (bool, error) {
var one int
err := q.QueryRowContext(ctx, `SELECT 1 FROM billing_events WHERE event_id = $1`, eventID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func insertBillingEvent(ctx context.Context, q txQueryer, input CheckoutInput, eventType string) error {
_, err := q.ExecContext(ctx, `
INSERT INTO billing_events (
event_id, event_type, stripe_session_id, stripe_subscription_id, stripe_customer_id
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (event_id) DO NOTHING
`, input.StripeEventID, eventType, input.StripeSessionID, input.StripeSubscriptionID, input.StripeCustomerID)
return err
}
func scanAccount(row *sql.Row) (*Account, error) {
var acct Account
var stripe sql.NullString
err := row.Scan(&acct.ID, &acct.PrimaryEmail, &acct.DisplayName, &stripe,
&acct.SubscriptionStatus, &acct.CreatedAt, &acct.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
acct.StripeCustomerID = stripe.String
return &acct, nil
}
func scanInstance(row *sql.Row) (*Instance, error) {
var inst Instance
err := row.Scan(&inst.ID, &inst.AccountID, &inst.Slug, &inst.StackName,
&inst.CustomerDomain, &inst.State, &inst.LastDeployedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &inst, nil
}

View File

@ -0,0 +1,36 @@
package accounts
import "strings"
func SlugFromEmail(email string) string {
parts := strings.SplitN(email, "@", 2)
local := parts[0]
domain := ""
if len(parts) == 2 {
domainParts := strings.Split(parts[1], ".")
if len(domainParts) >= 2 {
domain = "-" + domainParts[len(domainParts)-2]
}
}
return cleanSlug(local) + cleanSlug(domain)
}
func cleanSlug(s string) string {
out := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
if r == '_' {
return '-'
}
return '-'
}, strings.ToLower(s))
out = strings.Trim(out, "-")
if out == "" {
return "customer"
}
for strings.Contains(out, "--") {
out = strings.ReplaceAll(out, "--", "-")
}
return out
}

View File

@ -0,0 +1,172 @@
package accounts
import (
"context"
"database/sql"
"errors"
"strings"
_ "github.com/lib/pq"
)
var ErrNotFound = errors.New("account not found")
type Store struct {
db *sql.DB
}
func New(ctx context.Context, databaseURL string) (*Store, error) {
if strings.TrimSpace(databaseURL) == "" {
return nil, errors.New("DATABASE_URL is required")
}
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, err
}
s := &Store{db: db}
if err := s.Migrate(ctx); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
func (s *Store) Migrate(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS accounts (
id BIGSERIAL PRIMARY KEY,
primary_email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
stripe_customer_id TEXT UNIQUE,
subscription_status TEXT NOT NULL DEFAULT 'none',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS account_identities (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_subject TEXT NOT NULL,
email_at_login TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(provider, provider_subject)
);
CREATE TABLE IF NOT EXISTS instances (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
slug TEXT NOT NULL UNIQUE,
stack_name TEXT NOT NULL UNIQUE,
customer_domain TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'pending',
last_deployed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS billing_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
stripe_session_id TEXT NOT NULL DEFAULT '',
stripe_subscription_id TEXT NOT NULL DEFAULT '',
stripe_customer_id TEXT NOT NULL DEFAULT '',
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS accounts_subscription_status_idx ON accounts(subscription_status);
CREATE INDEX IF NOT EXISTS instances_account_id_idx ON instances(account_id);
`)
return err
}
func (s *Store) CountCustomers(ctx context.Context) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `
SELECT count(*) FROM accounts
WHERE stripe_customer_id IS NOT NULL AND stripe_customer_id <> ''
`).Scan(&count)
return count, err
}
func (s *Store) UpsertFromIdentity(ctx context.Context, identity Identity) (*Account, error) {
if identity.Provider == "" {
identity.Provider = "authentik"
}
if identity.Subject == "" {
identity.Subject = firstNonEmpty(identity.Username, identity.Email)
}
if identity.Subject == "" {
return nil, ErrNotFound
}
email := strings.ToLower(strings.TrimSpace(identity.Email))
displayName := strings.TrimSpace(firstNonEmpty(identity.Name, identity.Username, email))
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
acct, err := accountByIdentity(ctx, tx, identity.Provider, identity.Subject)
if err == nil {
if email != "" && !strings.EqualFold(acct.PrimaryEmail, email) {
emailAcct, emailErr := accountByEmail(ctx, tx, email)
if emailErr == nil && emailAcct.ID != acct.ID {
if err := linkIdentity(ctx, tx, emailAcct.ID, identity.Provider, identity.Subject, email); err != nil {
return nil, err
}
if displayName != "" {
if err := updateAccountProfile(ctx, tx, emailAcct.ID, "", displayName); err != nil {
return nil, err
}
}
acct, err := accountByID(ctx, tx, emailAcct.ID)
if err != nil {
return nil, err
}
return acct, tx.Commit()
}
if emailErr != nil && !errors.Is(emailErr, ErrNotFound) {
return nil, emailErr
}
}
if email != "" || displayName != "" {
if err := updateAccountProfile(ctx, tx, acct.ID, email, displayName); err != nil {
return nil, err
}
acct, _ = accountByID(ctx, tx, acct.ID)
}
return acct, tx.Commit()
}
if !errors.Is(err, ErrNotFound) {
return nil, err
}
if email == "" {
return nil, ErrNotFound
}
acct, err = upsertAccountByEmail(ctx, tx, email, displayName)
if err != nil {
return nil, err
}
if err := linkIdentity(ctx, tx, acct.ID, identity.Provider, identity.Subject, email); err != nil {
return nil, err
}
return acct, tx.Commit()
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}

View File

@ -0,0 +1,78 @@
package accounts
import (
"context"
"os"
"testing"
"time"
)
func TestStoreIntegration(t *testing.T) {
databaseURL := os.Getenv("SSATLAS_TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("set SSATLAS_TEST_DATABASE_URL to run Postgres integration test")
}
ctx := context.Background()
store, err := New(ctx, databaseURL)
if err != nil {
t.Fatalf("New() error = %v", err)
}
t.Cleanup(func() {
_, _ = store.db.ExecContext(ctx, "TRUNCATE billing_events, account_identities, instances, accounts RESTART IDENTITY CASCADE")
_ = store.Close()
})
email := "buyer-" + time.Now().UTC().Format("20060102150405") + "@example.com"
acct, inst, err := store.UpsertCheckout(ctx, CheckoutInput{
Email: email,
DisplayName: "Buyer Example",
Phone: "+15551234567",
CustomerDomain: "example.com",
StripeCustomerID: "cus_test_store",
StripeSessionID: "cs_test_store",
StripeEventID: "evt_test_store",
})
if err != nil {
t.Fatalf("UpsertCheckout() error = %v", err)
}
if acct.ID == 0 || inst.ID == 0 {
t.Fatalf("expected persisted account and instance, got account=%+v instance=%+v", acct, inst)
}
if inst.Slug == "" || inst.StackName == "" {
t.Fatalf("expected instance slug and stack name, got %+v", inst)
}
count, err := store.CountCustomers(ctx)
if err != nil {
t.Fatalf("CountCustomers() error = %v", err)
}
if count != 1 {
t.Fatalf("CountCustomers() = %d, want 1", count)
}
linked, err := store.UpsertFromIdentity(ctx, Identity{
Provider: "authentik",
Subject: "ak-user-1",
Username: "buyer",
Email: email,
Name: "Buyer Example",
})
if err != nil {
t.Fatalf("UpsertFromIdentity() error = %v", err)
}
if linked.ID != acct.ID {
t.Fatalf("identity linked account %d, want %d", linked.ID, acct.ID)
}
owned, err := store.InstanceBySlug(ctx, inst.Slug)
if err != nil {
t.Fatalf("InstanceBySlug() error = %v", err)
}
if owned.AccountID != acct.ID {
t.Fatalf("InstanceBySlug().AccountID = %d, want %d", owned.AccountID, acct.ID)
}
if err := store.MarkSubscriptionStatus(ctx, "cus_test_store", "cancelled"); err != nil {
t.Fatalf("MarkSubscriptionStatus() error = %v", err)
}
}

View File

@ -7,68 +7,65 @@ import (
) )
type Config struct { type Config struct {
Port string Port string
AppURL string AppURL string
AutheliaURL string IdentityURL string
AutheliaInternalURL string DatabaseURL string
StripeSecretKey string StripeSecretKey string
StripeWebhookSecret string StripeWebhookSecret string
StripePriceID string // Fallback when tier prices not set StripePriceID string // Fallback when tier prices not set
StripePaymentLink string // Optional: legacy Payment Link for $0 StripePaymentLink string // Optional: legacy Payment Link for $0
StripePriceIDFree string // $0/3mo, auto-cancel (first 10) StripePriceIDFree string // $0/3mo, auto-cancel (first 10)
StripePriceIDYear string // $20/year (customers 1150) StripePriceIDYear string // $20/year (customers 1150)
StripePriceIDMonth100 string // $100/month (after year for 1150) StripePriceIDMonth100 string // $100/month (after year for 1150)
StripePriceIDMonth200 string // $200/month (customers 51+) StripePriceIDMonth200 string // $200/month (customers 51+)
FreeTierLimit int // First N get free tier (default 10) FreeTierLimit int // First N get free tier (default 10)
YearTierLimit int // Up to this count get year tier (default 50) YearTierLimit int // Up to this count get year tier (default 50)
MaxSignups int // Cap on new signups (0 = no limit) MaxSignups int // Cap on new signups (0 = no limit)
LDAPUrl string DockerHost string
LDAPAdminDN string TraefikDomain string
LDAPAdminPassword string TraefikNetwork string
LDAPBaseDN string TraefikDockerNetwork string
LLDAPHttpURL string TemplatePath string
DockerHost string CustomerDomain string
TraefikDomain string ArchivePath string
TraefikNetwork string LandingTagline string // Main tagline under logo
TemplatePath string LandingFeatures []string // Bullet points on subscribe card (comma-separated in env)
CustomerDomain string AdminSecret string // If set, enables POST /admin/delete-user (X-Admin-Secret header)
ArchivePath string
LandingTagline string // Main tagline under logo
LandingFeatures []string // Bullet points on subscribe card (comma-separated in env)
} }
func Load() *Config { func Load() *Config {
traefikNetwork := envOrDefault("TRAEFIK_NETWORK", "atlas_internal")
traefikDockerNetwork := envOrDefault("TRAEFIK_DOCKER_NETWORK", "atlas_"+traefikNetwork)
return &Config{ return &Config{
Port: envOrDefault("PORT", "8080"), Port: envOrDefault("PORT", "8080"),
AppURL: envOrDefault("APP_URL", "https://bc.a250.ca"), AppURL: envOrDefault("APP_URL", "https://bc.a250.ca"),
AutheliaURL: envOrDefault("AUTHELIA_URL", "https://bc.a250.ca/login"), IdentityURL: envOrDefault("IDENTITY_URL", "https://bc.a250.ca/login"),
AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091/login"), DatabaseURL: envOrDefault("DATABASE_URL", "postgres://atlas:atlas@postgres:5432/atlas?sslmode=disable"),
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""), StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""), StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""), StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
StripePaymentLink: envOrDefault("STRIPE_PAYMENT_LINK", ""), StripePaymentLink: envOrDefault("STRIPE_PAYMENT_LINK", ""),
StripePriceIDFree: envOrDefault("STRIPE_PRICE_ID_FREE", ""), StripePriceIDFree: envOrDefault("STRIPE_PRICE_ID_FREE", ""),
StripePriceIDYear: envOrDefault("STRIPE_PRICE_ID_YEAR", ""), StripePriceIDYear: envOrDefault("STRIPE_PRICE_ID_YEAR", ""),
StripePriceIDMonth100: envOrDefault("STRIPE_PRICE_ID_MONTH_100", ""), StripePriceIDMonth100: envOrDefault("STRIPE_PRICE_ID_MONTH_100", ""),
StripePriceIDMonth200: envOrDefault("STRIPE_PRICE_ID_MONTH_200", ""), StripePriceIDMonth200: envOrDefault("STRIPE_PRICE_ID_MONTH_200", ""),
FreeTierLimit: envIntOrDefault("FREE_TIER_LIMIT", 10), FreeTierLimit: envIntOrDefault("FREE_TIER_LIMIT", 10),
YearTierLimit: envIntOrDefault("YEAR_TIER_LIMIT", 50), YearTierLimit: envIntOrDefault("YEAR_TIER_LIMIT", 50),
MaxSignups: envIntOrDefault("MAX_SIGNUPS", 0), MaxSignups: envIntOrDefault("MAX_SIGNUPS", 0),
LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"), DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"), TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""), TraefikNetwork: traefikNetwork,
LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"), TraefikDockerNetwork: traefikDockerNetwork,
LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"), TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"),
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"), CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "bc.a250.ca"),
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"), ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"),
TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"),
TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"),
CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "bc.a250.ca"),
ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"),
LandingTagline: envOrDefault("LANDING_TAGLINE", LandingTagline: envOrDefault("LANDING_TAGLINE",
"Your own workspace, ready in minutes."), "Your own workspace, ready in minutes."),
LandingFeatures: envListOrDefault("LANDING_FEATURES", LandingFeatures: envListOrDefault("LANDING_FEATURES",
[]string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}), []string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}),
AdminSecret: os.Getenv("ADMIN_SECRET"),
} }
} }
@ -104,4 +101,3 @@ func envListOrDefault(key string, fallback []string) []string {
} }
return fallback return fallback
} }

View File

@ -26,10 +26,9 @@ func TestEnvOrDefault(t *testing.T) {
func TestLoadDefaults(t *testing.T) { func TestLoadDefaults(t *testing.T) {
// Clear env vars that Load uses // Clear env vars that Load uses
envKeys := []string{ envKeys := []string{
"PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY", "PORT", "APP_URL", "IDENTITY_URL", "DATABASE_URL", "STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "STRIPE_PAYMENT_LINK", "MAX_SIGNUPS", "LLDAP_URL", "STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "STRIPE_PAYMENT_LINK", "MAX_SIGNUPS",
"LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN", "DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TRAEFIK_DOCKER_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
} }
for _, k := range envKeys { for _, k := range envKeys {
os.Unsetenv(k) os.Unsetenv(k)
@ -49,13 +48,12 @@ func TestLoadDefaults(t *testing.T) {
}{ }{
{"Port", cfg.Port, "8080"}, {"Port", cfg.Port, "8080"},
{"AppURL", cfg.AppURL, "https://bc.a250.ca"}, {"AppURL", cfg.AppURL, "https://bc.a250.ca"},
{"AutheliaURL", cfg.AutheliaURL, "https://bc.a250.ca/login"}, {"IdentityURL", cfg.IdentityURL, "https://bc.a250.ca/login"},
{"LDAPUrl", cfg.LDAPUrl, "ldap://lldap_lldap:3890"}, {"DatabaseURL", cfg.DatabaseURL, "postgres://atlas:atlas@postgres:5432/atlas?sslmode=disable"},
{"LDAPAdminDN", cfg.LDAPAdminDN, "uid=admin,ou=people,dc=a250,dc=ca"},
{"LDAPBaseDN", cfg.LDAPBaseDN, "dc=a250,dc=ca"},
{"DockerHost", cfg.DockerHost, "unix:///var/run/docker.sock"}, {"DockerHost", cfg.DockerHost, "unix:///var/run/docker.sock"},
{ "TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"}, {"TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
{"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"}, {"TraefikNetwork", cfg.TraefikNetwork, "atlas_internal"},
{"TraefikDockerNetwork", cfg.TraefikDockerNetwork, "atlas_atlas_internal"},
{"TemplatePath", cfg.TemplatePath, "/app/templates"}, {"TemplatePath", cfg.TemplatePath, "/app/templates"},
{"CustomerDomain", cfg.CustomerDomain, "bc.a250.ca"}, {"CustomerDomain", cfg.CustomerDomain, "bc.a250.ca"},
} }

View File

@ -1,16 +1,15 @@
package handlers package handlers
import ( import (
"fmt"
"log" "log"
"net/http" "net/http"
) )
func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) { func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User") acct, identity, err := a.currentAccount(r)
if remoteUser == "" { if err != nil || acct == nil {
data := map[string]any{ data := map[string]any{
"AutheliaURL": a.cfg.AutheliaURL, "IdentityURL": a.cfg.IdentityURL,
"AppURL": a.cfg.AppURL, "AppURL": a.cfg.AppURL,
"NeedLogin": true, "NeedLogin": true,
} }
@ -18,16 +17,15 @@ func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
return return
} }
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers") if isSubscribedAccount(acct) {
if inGroup {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return return
} }
data := map[string]any{ data := map[string]any{
"User": remoteUser, "User": accountDisplay(acct, identity),
"AppURL": a.cfg.AppURL, "AppURL": a.cfg.AppURL,
"Ready": true, "Ready": true,
} }
if err := a.tmpl.ExecuteTemplate(w, "activate.html", data); err != nil { if err := a.tmpl.ExecuteTemplate(w, "activate.html", data); err != nil {
log.Printf("template error: %v", err) log.Printf("template error: %v", err)
@ -36,32 +34,39 @@ func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) handleActivatePost(w http.ResponseWriter, r *http.Request) { func (a *App) handleActivatePost(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User") acct, _, err := a.currentAccount(r)
if remoteUser == "" { if err != nil || acct == nil {
http.Error(w, "not authenticated", http.StatusUnauthorized) http.Error(w, "not authenticated", http.StatusUnauthorized)
return return
} }
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers") if isSubscribedAccount(acct) {
if inGroup {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return return
} }
if acct.StripeCustomerID == "" {
if err := a.ldap.AddToGroup(remoteUser, "customers"); err != nil { http.Error(w, "no paid checkout found", http.StatusForbidden)
log.Printf("activate: group add failed for %s: %v", remoteUser, err)
http.Error(w, "activation failed, contact support", http.StatusInternalServerError)
return return
} }
stackName := fmt.Sprintf("customer-%s", remoteUser) inst, err := a.accounts.InstanceByAccountID(r.Context(), acct.ID)
if err := a.swarm.RestoreVolumes(stackName, a.cfg.ArchivePath); err != nil { if err != nil {
log.Printf("activate: volume restore failed for %s: %v", remoteUser, err) log.Printf("activate: instance lookup failed for account %d: %v", acct.ID, err)
http.Error(w, "activation failed, contact support", http.StatusInternalServerError)
return
} }
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil { if err := a.accounts.MarkSubscriptionStatus(r.Context(), acct.StripeCustomerID, "active"); err != nil {
log.Printf("activate: stack deploy failed for %s: %v", remoteUser, err) log.Printf("activate: subscription status update failed for account %d: %v", acct.ID, err)
}
if err := a.swarm.RestoreVolumes(inst.StackName, a.cfg.ArchivePath); err != nil {
log.Printf("activate: volume restore failed for %s: %v", inst.StackName, err)
}
if err := a.swarm.DeployStack(inst.StackName, inst.Slug, a.cfg.TraefikDomain); err != nil {
log.Printf("activate: stack deploy failed for %s: %v", inst.StackName, err)
} else if err := a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true); err != nil {
log.Printf("activate: state update failed for %s: %v", inst.StackName, err)
} }
log.Printf("activated user %s: group=customers stack=%s", remoteUser, stackName) log.Printf("activated account %d stack=%s", acct.ID, inst.StackName)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
} }

View File

@ -0,0 +1,64 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"regexp"
)
var validUsername = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
// handleDeleteUser fully deletes an account plus its customer stack and volumes.
// Requires ADMIN_SECRET env set and X-Admin-Secret header. POST /admin/delete-user?user=instance-slug
func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if a.cfg.AdminSecret == "" {
http.NotFound(w, r)
return
}
secret := r.Header.Get("X-Admin-Secret")
if secret != a.cfg.AdminSecret {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
slug := r.URL.Query().Get("user")
if slug == "" {
slug = r.FormValue("user")
}
if slug == "" {
http.Error(w, "user required", http.StatusBadRequest)
return
}
if !validUsername.MatchString(slug) {
http.Error(w, "invalid username", http.StatusBadRequest)
return
}
inst, err := a.accounts.DeleteAccountByInstanceSlug(r.Context(), slug)
if err != nil {
log.Printf("admin delete-user %s: account: %v", slug, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
if err := a.swarm.RemoveStackAndVolumes(inst.StackName); err != nil {
log.Printf("admin delete-user %s: stack/volumes: %v", slug, err)
// Account already deleted; report but don't fail.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "user deleted",
"warning": "stack/volumes: " + err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "user": slug})
}

View File

@ -0,0 +1,67 @@
package handlers
import (
"net/http"
"strings"
"git.nixc.us/a250/ss-atlas/internal/accounts"
)
func identityFromRequest(r *http.Request) accounts.Identity {
username := firstHeader(r, "X-authentik-username", "Remote-User")
email := firstHeader(r, "X-authentik-email", "Remote-Email", "X-Forwarded-Email", "X-Auth-Request-Email", "X-Email")
name := firstHeader(r, "X-authentik-name", "Remote-Name", "X-Forwarded-User", "X-Auth-Request-User")
groups := firstHeader(r, "X-authentik-groups", "Remote-Groups")
subject := firstHeader(r, "X-authentik-uid", "X-authentik-username", "Remote-User")
return accounts.Identity{
Provider: "authentik",
Subject: strings.TrimSpace(subject),
Username: strings.TrimSpace(username),
Email: strings.TrimSpace(email),
Name: strings.TrimSpace(name),
Groups: strings.TrimSpace(groups),
}
}
func (a *App) currentAccount(r *http.Request) (*accounts.Account, accounts.Identity, error) {
identity := identityFromRequest(r)
if identity.Subject == "" && identity.Email == "" {
return nil, identity, accounts.ErrNotFound
}
if a.accounts == nil {
return nil, identity, accounts.ErrNotFound
}
acct, err := a.accounts.UpsertFromIdentity(r.Context(), identity)
return acct, identity, err
}
func firstHeader(r *http.Request, names ...string) string {
for _, name := range names {
if value := r.Header.Get(name); value != "" {
return value
}
}
return ""
}
func accountDisplay(acct *accounts.Account, identity accounts.Identity) string {
if identity.Email != "" {
return identity.Email
}
if acct != nil {
return acct.PrimaryEmail
}
if identity.Username != "" {
return identity.Username
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@ -1,117 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"strings"
)
func (a *App) handleResendReset(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
if username == "" {
if email := r.FormValue("email"); email != "" {
username = sanitizeUsername(email)
}
}
if username == "" {
respondResendError(w, http.StatusBadRequest, "email or username required", 0)
return
}
if ok, retryAfter := resendRateLimiter.allow(username); !ok {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
respondResendError(w, http.StatusTooManyRequests,
"please wait before requesting another email", retryAfter)
return
}
if err := a.triggerPasswordReset(r, username); err != nil {
log.Printf("resend-reset: failed for %s: %v", username, err)
respondResendError(w, http.StatusInternalServerError, "failed to send email", 0)
return
}
resendRateLimiter.record(username)
log.Printf("resend-reset: password reset email sent for %s", username)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"message": "Password setup email sent. Check your inbox.",
})
}
func respondResendError(w http.ResponseWriter, code int, msg string, retryAfter int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
body := map[string]any{"ok": false, "error": msg}
if retryAfter > 0 {
body["retry_after_seconds"] = retryAfter
}
json.NewEncoder(w).Encode(body)
}
func clientIP(r *http.Request) string {
if s := r.Header.Get("X-Forwarded-For"); s != "" {
if idx := strings.Index(s, ","); idx > 0 {
return strings.TrimSpace(s[:idx])
}
return strings.TrimSpace(s)
}
if s := r.Header.Get("X-Real-IP"); s != "" {
return s
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
func (a *App) triggerPasswordReset(r *http.Request, username string) error {
url := a.cfg.AutheliaInternalURL + "/api/reset-password/identity/start"
body, _ := json.Marshal(map[string]string{"username": username})
log.Printf("triggerPasswordReset: POST %s for user %q", url, username)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("authelia reset build request: %w", err)
}
externalHost := strings.TrimPrefix(strings.TrimPrefix(a.cfg.AutheliaURL, "https://"), "http://")
proto := "http"
if strings.HasPrefix(a.cfg.AutheliaURL, "https://") {
proto = "https"
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-Host", externalHost)
req.Header.Set("X-Forwarded-Proto", proto)
req.Header.Set("X-Forwarded-For", clientIP(r))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("authelia reset request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
log.Printf("triggerPasswordReset: status=%d body=%s", resp.StatusCode, string(respBody))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("authelia reset returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Status string `json:"status"`
}
if json.Unmarshal(respBody, &result) == nil && result.Status == "KO" {
return fmt.Errorf("authelia reset rejected: %s", string(respBody))
}
return nil
}

View File

@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"fmt"
"log" "log"
"net/http" "net/http"
@ -10,18 +9,10 @@ import (
) )
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User") acct, identity, err := a.currentAccount(r)
remoteEmail := r.Header.Get("Remote-Email") remoteUser := accountDisplay(acct, identity)
remoteGroups := r.Header.Get("Remote-Groups") remoteEmail := firstNonEmpty(identity.Email, "")
isSubscribed := remoteGroups != "" && contains(remoteGroups, "customers") isSubscribed := isSubscribedAccount(acct)
// Authelia session may be stale (user was added to customers after login).
if !isSubscribed && remoteUser != "" {
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers")
if inGroup {
isSubscribed = true
}
}
var customerID string var customerID string
stackDeployed := false stackDeployed := false
@ -29,59 +20,62 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
var subStatus *ssstripe.SubscriptionStatus var subStatus *ssstripe.SubscriptionStatus
paidNotActivated := false paidNotActivated := false
if remoteUser != "" { if err != nil {
cid, _ := a.ldap.GetStripeCustomerID(remoteUser) log.Printf("dashboard: account lookup failed: %v", err)
if cid != "" && !isSubscribed { }
paidNotActivated = true if acct != nil && acct.StripeCustomerID != "" && !isSubscribed {
} paidNotActivated = true
} }
if isSubscribed && remoteUser != "" { var instSlug string
cid, err := a.ldap.GetStripeCustomerID(remoteUser) var customerDomain string
if err != nil { if isSubscribed && acct != nil {
log.Printf("dashboard: failed to get stripe customer id for %s: %v", remoteUser, err) customerID = acct.StripeCustomerID
} if customerID != "" {
customerID = cid subStatus = a.stripe.GetCustomerSubscriptionStatus(customerID)
if cid != "" {
subStatus = a.stripe.GetCustomerSubscriptionStatus(cid)
} }
if subStatus == nil { if subStatus == nil {
subStatus = &ssstripe.SubscriptionStatus{Label: "Active", Badge: "badge-active"} subStatus = &ssstripe.SubscriptionStatus{Label: "Active", Badge: "badge-active"}
} }
stackName := fmt.Sprintf("customer-%s", remoteUser) inst, err := a.accounts.InstanceByAccountID(r.Context(), acct.ID)
exists, err := a.swarm.StackExists(stackName) if err == nil {
if err != nil { instSlug = inst.Slug
log.Printf("dashboard: stack check failed for %s: %v", remoteUser, err) customerDomain = inst.CustomerDomain
exists, err := a.swarm.StackExists(inst.StackName)
if err != nil {
log.Printf("dashboard: stack check failed for %s: %v", inst.StackName, err)
}
stackDeployed = exists
if exists {
replicas, _ := a.swarm.GetWebReplicas(inst.StackName)
stackRunning = replicas > 0
}
} else {
log.Printf("dashboard: instance lookup failed for account %d: %v", acct.ID, err)
} }
stackDeployed = exists
if exists {
replicas, _ := a.swarm.GetWebReplicas(stackName)
stackRunning = replicas > 0
}
}
customerDomain := ""
if remoteUser != "" {
customerDomain, _ = a.ldap.GetCustomerDomain(remoteUser)
} }
data := map[string]any{ data := map[string]any{
"AppURL": a.cfg.AppURL, "AppURL": a.cfg.AppURL,
"AutheliaURL": a.cfg.AutheliaURL, "IdentityURL": a.cfg.IdentityURL,
"User": remoteUser, "User": remoteUser,
"Email": remoteEmail, "Email": remoteEmail,
"Groups": remoteGroups, "Groups": identity.Groups,
"Domain": a.cfg.TraefikDomain, "Domain": a.cfg.TraefikDomain,
"InstanceSlug": instSlug,
"IsSubscribed": isSubscribed, "IsSubscribed": isSubscribed,
"PaidNotActivated": paidNotActivated, "PaidNotActivated": paidNotActivated,
"CustomerID": customerID, "CustomerID": customerID,
"SubStatus": subStatus, "SubStatus": subStatus,
"StackDeployed": stackDeployed, "StackDeployed": stackDeployed,
"StackRunning": stackRunning, "StackRunning": stackRunning,
"CustomerDomain": customerDomain, "CustomerDomain": customerDomain,
"Commit": version.Commit, "StackError": r.URL.Query().Get("stack_error"),
"BuildTime": version.BuildTime, "PortalError": r.URL.Query().Get("portal_error"),
"Linked": r.URL.Query().Get("linked") == "1",
"Commit": version.Commit,
"BuildTime": version.BuildTime,
} }
if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil { if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {

View File

@ -94,4 +94,3 @@ func TestHealthRoute(t *testing.T) {
t.Errorf("GET /health body = %q, want ok", body) t.Errorf("GET /health body = %q, want ok", body)
} }
} }

View File

@ -0,0 +1,65 @@
package handlers
import (
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
)
// instanceUsernamePattern matches valid instance slugs.
var instanceUsernamePattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// handleInstanceProxy enforces account ownership for /i/<slug>, then
// reverse-proxies to that customer stack's web service.
func (a *App) handleInstanceProxy(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "username")
if slug == "" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !instanceUsernamePattern.MatchString(slug) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
acct, identity, err := a.currentAccount(r)
if err != nil || acct == nil {
http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
return
}
inst, err := a.accounts.InstanceBySlug(r.Context(), slug)
if err != nil || inst.AccountID != acct.ID {
log.Printf("instance proxy: denied %s access to /i/%s", accountDisplay(acct, identity), slug)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
backendHost := fmt.Sprintf("web.%s", inst.StackName)
backendURL := &url.URL{
Scheme: "http",
Host: backendHost + ":3001",
Path: "/",
}
prefix := "/i/" + slug
pathSuffix := strings.TrimPrefix(r.URL.Path, prefix)
if pathSuffix == "" {
pathSuffix = "/"
}
if !strings.HasPrefix(pathSuffix, "/") {
pathSuffix = "/" + pathSuffix
}
proxy := httputil.NewSingleHostReverseProxy(backendURL)
proxy.Director = func(req *http.Request) {
req.URL.Scheme = backendURL.Scheme
req.URL.Host = backendURL.Host
req.URL.Path = pathSuffix
req.Host = backendURL.Host
}
proxy.ServeHTTP(w, r)
}

View File

@ -0,0 +1,42 @@
package handlers
import (
"encoding/json"
"net"
"net/http"
"strings"
)
func (a *App) handleResendReset(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"message": "Use the sign-in button to continue with your social account.",
})
}
func respondResendError(w http.ResponseWriter, code int, msg string, retryAfter int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
body := map[string]any{"ok": false, "error": msg}
if retryAfter > 0 {
body["retry_after_seconds"] = retryAfter
}
json.NewEncoder(w).Encode(body)
}
func clientIP(r *http.Request) string {
if s := r.Header.Get("X-Forwarded-For"); s != "" {
if idx := strings.Index(s, ","); idx > 0 {
return strings.TrimSpace(s[:idx])
}
return strings.TrimSpace(s)
}
if s := r.Header.Get("X-Real-IP"); s != "" {
return s
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}

View File

@ -6,8 +6,8 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"git.nixc.us/a250/ss-atlas/internal/accounts"
"git.nixc.us/a250/ss-atlas/internal/config" "git.nixc.us/a250/ss-atlas/internal/config"
"git.nixc.us/a250/ss-atlas/internal/ldap"
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe" ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
"git.nixc.us/a250/ss-atlas/internal/swarm" "git.nixc.us/a250/ss-atlas/internal/swarm"
"git.nixc.us/a250/ss-atlas/internal/version" "git.nixc.us/a250/ss-atlas/internal/version"
@ -16,24 +16,24 @@ import (
) )
type App struct { type App struct {
cfg *config.Config cfg *config.Config
stripe *ssstripe.Client stripe *ssstripe.Client
ldap *ldap.Client accounts *accounts.Store
swarm *swarm.Client swarm *swarm.Client
tmpl *template.Template tmpl *template.Template
} }
func NewRouter(cfg *config.Config, sc *ssstripe.Client, lc *ldap.Client, sw *swarm.Client) http.Handler { func NewRouter(cfg *config.Config, sc *ssstripe.Client, accountStore *accounts.Store, sw *swarm.Client) http.Handler {
tmplPattern := filepath.Join(cfg.TemplatePath, "pages", "*.html") tmplPattern := filepath.Join(cfg.TemplatePath, "pages", "*.html")
partialsPattern := filepath.Join(cfg.TemplatePath, "partials", "*.html") partialsPattern := filepath.Join(cfg.TemplatePath, "partials", "*.html")
tmpl := template.Must(template.Must(template.ParseGlob(partialsPattern)).ParseGlob(tmplPattern)) tmpl := template.Must(template.Must(template.ParseGlob(partialsPattern)).ParseGlob(tmplPattern))
app := &App{ app := &App{
cfg: cfg, cfg: cfg,
stripe: sc, stripe: sc,
ldap: lc, accounts: accountStore,
swarm: sw, swarm: sw,
tmpl: tmpl, tmpl: tmpl,
} }
r := chi.NewRouter() r := chi.NewRouter()
@ -42,16 +42,22 @@ func NewRouter(cfg *config.Config, sc *ssstripe.Client, lc *ldap.Client, sw *swa
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Get("/", app.handleLanding) r.Get("/", app.handleLanding)
r.Get("/checkout", app.handleCheckout)
r.Get("/success", app.handleSuccess) r.Get("/success", app.handleSuccess)
r.Get("/activate", app.handleActivateGet) r.Get("/activate", app.handleActivateGet)
r.Post("/activate", app.handleActivatePost) r.Post("/activate", app.handleActivatePost)
r.Get("/dashboard", app.handleDashboard) r.Get("/dashboard", app.handleDashboard)
// Instance ownership is checked in Postgres before proxying to the customer stack.
r.Handle("/i/{username}", http.HandlerFunc(app.handleInstanceProxy))
r.Handle("/i/{username}/*", http.HandlerFunc(app.handleInstanceProxy))
r.Post("/stack-manage", app.handleStackManage) r.Post("/stack-manage", app.handleStackManage)
r.Post("/subscribe", app.handleCreateCheckout) r.Post("/subscribe", app.handleCreateCheckout)
r.Post("/resend-reset", app.handleResendReset) r.Post("/resend-reset", app.handleResendReset)
r.Post("/link-stripe-customer", app.handleLinkStripeCustomer)
r.Post("/portal", app.handlePortal) r.Post("/portal", app.handlePortal)
r.Post("/resubscribe", app.handleResubscribe) r.Post("/resubscribe", app.handleResubscribe)
r.Post("/webhook/stripe", app.handleWebhook) r.Post("/webhook/stripe", app.handleWebhook)
r.Post("/admin/delete-user", app.handleDeleteUser)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("ok")) w.Write([]byte("ok"))

View File

@ -1,70 +1,79 @@
package handlers package handlers
import ( import (
"fmt"
"log" "log"
"net/http" "net/http"
"net/url"
) )
func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) { func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User") acct, _, err := a.currentAccount(r)
remoteGroups := r.Header.Get("Remote-Groups") if err != nil || acct == nil {
if remoteUser == "" { http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
http.Redirect(w, r, a.cfg.AutheliaURL, http.StatusSeeOther)
return return
} }
if !contains(remoteGroups, "customers") { if !isSubscribedAccount(acct) {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
inst, err := a.accounts.InstanceByAccountID(r.Context(), acct.ID)
if err != nil {
log.Printf("stack-manage instance lookup account=%d: %v", acct.ID, err)
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return
}
action := r.FormValue("action") action := r.FormValue("action")
stackName := fmt.Sprintf("customer-%s", remoteUser)
switch action { switch action {
case "stop": case "stop":
if err := a.swarm.ScaleStack(stackName, 0); err != nil { if err := a.swarm.ScaleStack(inst.StackName, 0); err != nil {
log.Printf("stack-manage stop %s: %v", remoteUser, err) log.Printf("stack-manage stop %s: %v", inst.StackName, err)
http.Error(w, "failed to stop stack", http.StatusInternalServerError) redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return return
} }
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "stopped", false)
case "start": case "start":
exists, _ := a.swarm.StackExists(stackName) exists, _ := a.swarm.StackExists(inst.StackName)
if !exists { if !exists {
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil { if err := a.swarm.DeployStack(inst.StackName, inst.Slug, a.cfg.TraefikDomain); err != nil {
log.Printf("stack-manage start (deploy) %s: %v", remoteUser, err) log.Printf("stack-manage start (deploy) %s: %v", inst.StackName, err)
http.Error(w, "failed to start stack", http.StatusInternalServerError) redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return return
} }
} else { } else {
if err := a.swarm.ScaleStack(stackName, 1); err != nil { if err := a.swarm.ScaleStack(inst.StackName, 1); err != nil {
log.Printf("stack-manage start (scale) %s: %v", remoteUser, err) log.Printf("stack-manage start (scale) %s: %v", inst.StackName, err)
http.Error(w, "failed to start stack", http.StatusInternalServerError) redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return return
} }
} }
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true)
case "restart": case "restart":
if err := a.swarm.RestartStack(stackName); err != nil { if err := a.swarm.RestartStack(inst.StackName); err != nil {
log.Printf("stack-manage restart %s: %v", remoteUser, err) log.Printf("stack-manage restart %s: %v", inst.StackName, err)
http.Error(w, "failed to restart stack", http.StatusInternalServerError) redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return return
} }
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true)
case "rebuild": case "rebuild":
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil { if err := a.swarm.DeployStack(inst.StackName, inst.Slug, a.cfg.TraefikDomain); err != nil {
log.Printf("stack-manage rebuild %s: %v", remoteUser, err) log.Printf("stack-manage rebuild %s: %v", inst.StackName, err)
http.Error(w, "failed to rebuild stack", http.StatusInternalServerError) redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return return
} }
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true)
case "destroy": case "destroy":
if err := a.swarm.RemoveStack(stackName); err != nil { if err := a.swarm.RemoveStack(inst.StackName); err != nil {
log.Printf("stack-manage destroy %s: %v", remoteUser, err) log.Printf("stack-manage destroy %s: %v", inst.StackName, err)
http.Error(w, "failed to destroy stack", http.StatusInternalServerError) redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return return
} }
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "destroyed", false)
default: default:
http.Error(w, "unknown action", http.StatusBadRequest) http.Error(w, "unknown action", http.StatusBadRequest)
@ -73,3 +82,11 @@ func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther) http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
} }
func redirectWithStackError(w http.ResponseWriter, r *http.Request, baseURL string, err error) {
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("stack_error", err.Error())
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusSeeOther)
}

View File

@ -1,37 +1,37 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"fmt"
"log" "log"
"net/http" "net/http"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
"git.nixc.us/a250/ss-atlas/internal/accounts"
"git.nixc.us/a250/ss-atlas/internal/pricing" "git.nixc.us/a250/ss-atlas/internal/pricing"
"git.nixc.us/a250/ss-atlas/internal/stripe" "git.nixc.us/a250/ss-atlas/internal/stripe"
"git.nixc.us/a250/ss-atlas/internal/validation" "git.nixc.us/a250/ss-atlas/internal/validation"
"git.nixc.us/a250/ss-atlas/internal/version" "git.nixc.us/a250/ss-atlas/internal/version"
stripego "github.com/stripe/stripe-go/v84"
) )
func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) { func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User") acct, _, _ := a.currentAccount(r)
if contains(r.Header.Get("Remote-Groups"), "customers") { if isSubscribedAccount(acct) {
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther) http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
return return
} }
// Logged-in user who paid but hasn't activated yet — send to activate. if acct != nil && acct.StripeCustomerID != "" {
if remoteUser != "" { http.Redirect(w, r, a.cfg.AppURL+"/activate", http.StatusSeeOther)
custID, _ := a.ldap.GetStripeCustomerID(remoteUser) return
if custID != "" {
http.Redirect(w, r, a.cfg.AppURL+"/activate", http.StatusSeeOther)
return
}
} }
count, _ := a.ldap.CountCustomers() count, _ := a.accounts.CountCustomers(r.Context())
soldOut := a.cfg.MaxSignups > 0 && count >= a.cfg.MaxSignups soldOut := a.cfg.MaxSignups > 0 && count >= a.cfg.MaxSignups
tier := pricing.ForCustomer(count, a.cfg.FreeTierLimit, a.cfg.YearTierLimit) tier := pricing.ForCustomer(count, a.cfg.FreeTierLimit, a.cfg.YearTierLimit)
@ -55,19 +55,59 @@ func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
} }
} }
func (a *App) handleCheckout(w http.ResponseWriter, r *http.Request) {
acct, _, err := a.currentAccount(r)
if err != nil || acct == nil {
http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
return
}
if isSubscribedAccount(acct) {
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
return
}
if acct.StripeCustomerID != "" {
http.Redirect(w, r, a.cfg.AppURL+"/activate", http.StatusSeeOther)
return
}
count, _ := a.accounts.CountCustomers(r.Context())
soldOut := a.cfg.MaxSignups > 0 && count >= a.cfg.MaxSignups
if soldOut {
http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable)
return
}
tier := pricing.ForCustomer(count, a.cfg.FreeTierLimit, a.cfg.YearTierLimit)
if err := a.tmpl.ExecuteTemplate(w, "checkout.html", map[string]any{
"AppURL": a.cfg.AppURL,
"Email": acct.PrimaryEmail,
"PricingTier": int(tier),
}); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) { func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
acct, identity, err := a.currentAccount(r)
if err != nil || acct == nil {
http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
return
}
if a.cfg.MaxSignups > 0 { if a.cfg.MaxSignups > 0 {
count, err := a.ldap.CountCustomers() count, err := a.accounts.CountCustomers(r.Context())
if err == nil && count >= a.cfg.MaxSignups { if err == nil && count >= a.cfg.MaxSignups {
http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable) http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable)
return return
} }
} }
rawEmail := r.FormValue("email") email := validation.SanitizeEmail(firstNonEmpty(acct.PrimaryEmail, identity.Email))
email := validation.SanitizeEmail(rawEmail)
if email == "" { if email == "" {
http.Error(w, "valid email required", http.StatusBadRequest) http.Error(w, "signed-in account is missing a valid email", http.StatusBadRequest)
return return
} }
domain := strings.TrimSpace(r.FormValue("domain")) domain := strings.TrimSpace(r.FormValue("domain"))
@ -86,8 +126,8 @@ func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
return return
} }
count, _ := a.ldap.CountCustomers() count, _ := a.accounts.CountCustomers(r.Context())
sess, err := a.stripe.CreateCheckoutSession(email, domain, phone, count) sess, err := a.stripe.CreateCheckoutSession(email, domain, phone, acct.ID, count)
if err != nil { if err != nil {
if errors.Is(err, stripe.ErrNoPriceForTier) { if errors.Is(err, stripe.ErrNoPriceForTier) {
http.Error(w, "pricing not configured for current tier — set STRIPE_PRICE_ID or tier prices in env", http.StatusServiceUnavailable) http.Error(w, "pricing not configured for current tier — set STRIPE_PRICE_ID or tier prices in env", http.StatusServiceUnavailable)
@ -109,7 +149,7 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
} }
if a.cfg.MaxSignups > 0 { if a.cfg.MaxSignups > 0 {
count, err := a.ldap.CountCustomers() count, err := a.accounts.CountCustomers(r.Context())
if err == nil && count >= a.cfg.MaxSignups { if err == nil && count >= a.cfg.MaxSignups {
http.Error(w, "signup limit reached, contact support", http.StatusServiceUnavailable) http.Error(w, "signup limit reached, contact support", http.StatusServiceUnavailable)
return return
@ -130,68 +170,101 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
email := sess.CustomerDetails.Email email := sess.CustomerDetails.Email
customerID := sess.Customer.ID customerID := sess.Customer.ID
username := sanitizeUsername(email)
phone := "" phone := ""
domain := ""
accountID := int64(0)
if sess.Metadata != nil { if sess.Metadata != nil {
phone = sess.Metadata["customer_phone"] phone = sess.Metadata["customer_phone"]
domain = sess.Metadata["customer_domain"]
accountID, _ = strconv.ParseInt(sess.Metadata["account_id"], 10, 64)
} }
result, err := a.ldap.ProvisionUser(username, email, customerID, phone) input := accounts.CheckoutInput{
AccountID: accountID,
Email: email,
DisplayName: email,
Phone: phone,
CustomerDomain: domain,
StripeCustomerID: customerID,
StripeSubscriptionID: subscriptionIDFromSession(sess),
StripeSessionID: sess.ID,
}
acct, inst, err := a.accounts.UpsertCheckout(r.Context(), input)
if err != nil { if err != nil {
log.Printf("ldap provision failed for %s: %v", email, err) log.Printf("account provision failed for %s: %v", email, err)
http.Error(w, "account creation failed, contact support", http.StatusInternalServerError) http.Error(w, "account creation failed, contact support", http.StatusInternalServerError)
return return
} }
if sess.Metadata != nil && sess.Metadata["customer_domain"] != "" { exists, _ := a.swarm.StackExists(inst.StackName)
if err := a.ldap.SetCustomerDomain(result.Username, sess.Metadata["customer_domain"]); err != nil { if !exists {
log.Printf("ldap set customer domain failed for %s: %v", result.Username, err) if err := a.swarm.RestoreVolumes(inst.StackName, a.cfg.ArchivePath); err != nil {
log.Printf("success: volume restore failed for %s: %v", inst.StackName, err)
}
if err := a.swarm.DeployStack(inst.StackName, inst.Slug, a.cfg.TraefikDomain); err != nil {
log.Printf("success: stack deploy failed for %s: %v", inst.StackName, err)
} else if err := a.accounts.UpdateInstanceState(context.Background(), inst.StackName, "running", true); err != nil {
log.Printf("success: update instance state failed for %s: %v", inst.StackName, err)
} }
} }
if err := a.tmpl.ExecuteTemplate(w, "success.html", map[string]any{
// Grant active subscription: add to customers group so dashboard shows subscribed. "AppURL": a.cfg.AppURL,
if err := a.ldap.AddToGroup(result.Username, "customers"); err != nil { "IdentityURL": a.cfg.IdentityURL,
log.Printf("ldap add to customers failed for %s: %v (create group 'customers' in LLDAP admin if missing)", result.Username, err) "Email": acct.PrimaryEmail,
}); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
} }
}
inGroup, _ := a.ldap.IsInGroup(result.Username, "customers") // handleLinkStripeCustomer creates a Stripe customer for the current user and saves the ID,
// so "Manage Subscription" works. Used when the user is in customers group but has no customer_id (e.g. manual add).
if result.IsNew || !inGroup { func (a *App) handleLinkStripeCustomer(w http.ResponseWriter, r *http.Request) {
// New or lapsed: send password email, show success page. acct, identity, err := a.currentAccount(r)
if err := a.triggerPasswordReset(r, result.Username); err != nil { if err != nil || acct == nil {
log.Printf("authelia reset trigger failed for %s: %v", username, err) http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
} else {
resendRateLimiter.record(result.Username)
}
if err := a.tmpl.ExecuteTemplate(w, "success.html", map[string]any{
"AppURL": a.cfg.AppURL,
"Username": result.Username,
}); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
return return
} }
if !isSubscribedAccount(acct) {
// Returning active customer: ensure stack exists, go to dashboard http.Error(w, "forbidden", http.StatusForbidden)
stackName := fmt.Sprintf("customer-%s", result.Username) return
exists, _ := a.swarm.StackExists(stackName)
if !exists {
if err := a.swarm.RestoreVolumes(stackName, a.cfg.ArchivePath); err != nil {
log.Printf("resubscribe: volume restore failed for %s: %v", result.Username, err)
}
if err := a.swarm.DeployStack(stackName, result.Username, a.cfg.TraefikDomain); err != nil {
log.Printf("resubscribe: stack deploy failed for %s: %v", result.Username, err)
}
} }
log.Printf("resubscribe: %s payment verified, redirecting to dashboard", result.Username) if acct.StripeCustomerID != "" {
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther) redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "Billing account already linked. Use Manage Subscription below.")
return
}
email := strings.TrimSpace(firstNonEmpty(identity.Email, acct.PrimaryEmail))
if email == "" {
redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "No email on account. Contact support to manage your subscription.")
return
}
customerID, err := a.stripe.CreateCustomer(email)
if err != nil {
log.Printf("link-stripe-customer: create customer failed for %s: %v", email, err)
redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "Failed to create billing account: "+err.Error())
return
}
if _, _, err := a.accounts.UpsertCheckout(r.Context(), accounts.CheckoutInput{
Email: email,
DisplayName: firstNonEmpty(identity.Name, identity.Username, email),
StripeCustomerID: customerID,
}); err != nil {
log.Printf("link-stripe-customer: set account failed for %s: %v", email, err)
redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "Billing account created but link failed. Contact support to manage your subscription.")
return
}
log.Printf("link-stripe-customer: linked account %d -> Stripe customer %s", acct.ID, customerID)
u, _ := url.Parse(a.cfg.AppURL + "/dashboard")
q := u.Query()
q.Set("linked", "1")
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusSeeOther)
} }
func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) { func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
customerID := r.FormValue("customer_id") customerID := r.FormValue("customer_id")
if customerID == "" { if customerID == "" {
http.Error(w, "customer_id required", http.StatusBadRequest) redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "No billing account linked. Contact support to manage your subscription.")
return return
} }
@ -211,11 +284,11 @@ func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) { func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) {
customerID := r.FormValue("customer_id") customerID := r.FormValue("customer_id")
if customerID == "" { if customerID == "" {
http.Error(w, "customer_id required", http.StatusBadRequest) redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "No billing account linked. Contact support to resubscribe.")
return return
} }
count, _ := a.ldap.CountCustomers() count, _ := a.accounts.CountCustomers(r.Context())
sess, err := a.stripe.CreateCheckoutForCustomer(customerID, count) sess, err := a.stripe.CreateCheckoutForCustomer(customerID, count)
if err != nil { if err != nil {
if errors.Is(err, stripe.ErrNoPriceForTier) { if errors.Is(err, stripe.ErrNoPriceForTier) {
@ -230,6 +303,10 @@ func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sess.URL, http.StatusSeeOther) http.Redirect(w, r, sess.URL, http.StatusSeeOther)
} }
func isSubscribedAccount(acct *accounts.Account) bool {
return acct != nil && acct.SubscriptionStatus == "active"
}
func sanitizeUsername(email string) string { func sanitizeUsername(email string) string {
parts := strings.SplitN(email, "@", 2) parts := strings.SplitN(email, "@", 2)
local := parts[0] local := parts[0]
@ -251,3 +328,18 @@ func sanitizeUsername(email string) string {
} }
return clean(local) + clean(domain) return clean(local) + clean(domain)
} }
func redirectWithPortalError(w http.ResponseWriter, r *http.Request, baseURL, message string) {
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("portal_error", message)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusSeeOther)
}
func subscriptionIDFromSession(sess *stripego.CheckoutSession) string {
if sess == nil || sess.Subscription == nil {
return ""
}
return sess.Subscription.ID
}

View File

@ -1,11 +1,15 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"log" "log"
"net/http" "net/http"
"strconv"
"git.nixc.us/a250/ss-atlas/internal/accounts"
stripego "github.com/stripe/stripe-go/v84" stripego "github.com/stripe/stripe-go/v84"
"github.com/stripe/stripe-go/v84/webhook" "github.com/stripe/stripe-go/v84/webhook"
) )
@ -43,8 +47,7 @@ func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// Reconciliation backstop: ensures LLDAP user + Stripe ID are set. // Reconciliation backstop: ensures the account, billing link, and instance exist.
// Does NOT send password reset — that's the success page's responsibility.
func (a *App) onCheckoutCompleted(event stripego.Event) { func (a *App) onCheckoutCompleted(event stripego.Event) {
var sess stripego.CheckoutSession var sess stripego.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
@ -54,34 +57,38 @@ func (a *App) onCheckoutCompleted(event stripego.Event) {
email := sess.CustomerDetails.Email email := sess.CustomerDetails.Email
customerID := sess.Customer.ID customerID := sess.Customer.ID
username := sanitizeUsername(email)
phone := "" phone := ""
domain := ""
accountID := int64(0)
if sess.Metadata != nil { if sess.Metadata != nil {
phone = sess.Metadata["customer_phone"] phone = sess.Metadata["customer_phone"]
domain = sess.Metadata["customer_domain"]
accountID, _ = strconv.ParseInt(sess.Metadata["account_id"], 10, 64)
} }
log.Printf("webhook: checkout completed email=%s customer=%s", email, customerID) log.Printf("webhook: checkout completed email=%s customer=%s account_id=%d", email, customerID, accountID)
if a.cfg.MaxSignups > 0 { if a.cfg.MaxSignups > 0 {
count, err := a.ldap.CountCustomers() count, err := a.accounts.CountCustomers(context.Background())
if err == nil && count >= a.cfg.MaxSignups { if err == nil && count >= a.cfg.MaxSignups {
log.Printf("webhook: signup limit reached (%d), skipping provision for %s", a.cfg.MaxSignups, email) log.Printf("webhook: signup limit reached (%d), skipping provision for %s", a.cfg.MaxSignups, email)
return return
} }
} }
if err := a.ldap.EnsureUser(username, email, customerID, phone); err != nil { _, _, err := a.accounts.UpsertCheckout(context.Background(), accounts.CheckoutInput{
log.Printf("webhook: ldap ensure user failed: %v", err) AccountID: accountID,
} Email: email,
if err := a.ldap.AddToGroup(username, "customers"); err != nil { DisplayName: email,
log.Printf("webhook: ldap add to customers failed for %s: %v", username, err) Phone: phone,
} CustomerDomain: domain,
if sess.Metadata != nil { StripeCustomerID: customerID,
if d := sess.Metadata["customer_domain"]; d != "" { StripeSubscriptionID: subscriptionIDFromSession(&sess),
if err := a.ldap.SetCustomerDomain(username, d); err != nil { StripeSessionID: sess.ID,
log.Printf("webhook: ldap set customer domain failed for %s: %v", username, err) StripeEventID: event.ID,
} })
} if err != nil {
log.Printf("webhook: account ensure failed: %v", err)
} }
subID := "" subID := ""
@ -112,26 +119,26 @@ func (a *App) onSubscriptionDeleted(event stripego.Event) {
customerID := sub.Customer.ID customerID := sub.Customer.ID
log.Printf("subscription deleted for customer %s", customerID) log.Printf("subscription deleted for customer %s", customerID)
username, err := a.ldap.FindUserByStripeID(customerID) _, inst, err := a.accounts.AccountByStripeCustomerID(context.Background(), customerID)
if err != nil { if err != nil {
log.Printf("could not find user for customer %s: %v", customerID, err) log.Printf("could not find account for customer %s: %v", customerID, err)
return return
} }
if err := a.ldap.RemoveFromGroup(username, "customers"); err != nil { if err := a.accounts.MarkSubscriptionStatus(context.Background(), customerID, "cancelled"); err != nil && !errors.Is(err, accounts.ErrNotFound) {
log.Printf("ldap group remove failed: %v", err) log.Printf("subscription status update failed: %v", err)
} }
stackName := "customer-" + username if err := a.swarm.ArchiveVolumes(inst.StackName, a.cfg.ArchivePath); err != nil {
if err := a.swarm.ArchiveVolumes(stackName, a.cfg.ArchivePath); err != nil { log.Printf("archive failed for %s: %v", inst.StackName, err)
log.Printf("archive failed for %s: %v", stackName, err)
} }
if err := a.swarm.RemoveStack(stackName); err != nil { if err := a.swarm.RemoveStack(inst.StackName); err != nil {
log.Printf("stack remove failed for %s: %v", stackName, err) log.Printf("stack remove failed for %s: %v", inst.StackName, err)
} }
_ = a.accounts.UpdateInstanceState(context.Background(), inst.StackName, "archived", false)
log.Printf("deprovisioned stack for customer %s (%s), volumes archived", customerID, username) log.Printf("deprovisioned stack for customer %s (%s), volumes archived", customerID, inst.Slug)
} }
func (a *App) onSubscriptionUpdated(event stripego.Event) { func (a *App) onSubscriptionUpdated(event stripego.Event) {
@ -143,6 +150,9 @@ func (a *App) onSubscriptionUpdated(event stripego.Event) {
if sub.Status == stripego.SubscriptionStatusCanceled || if sub.Status == stripego.SubscriptionStatusCanceled ||
sub.Status == stripego.SubscriptionStatusUnpaid { sub.Status == stripego.SubscriptionStatusUnpaid {
if sub.Customer != nil && sub.Customer.ID != "" {
_ = a.accounts.MarkSubscriptionStatus(context.Background(), sub.Customer.ID, string(sub.Status))
}
log.Printf("subscription %s status=%s, will be cleaned up on deletion", sub.ID, sub.Status) log.Printf("subscription %s status=%s, will be cleaned up on deletion", sub.ID, sub.Status)
} }
} }

View File

@ -11,6 +11,7 @@ import (
stripego "github.com/stripe/stripe-go/v84" stripego "github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session" portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v84/checkout/session" checkoutsession "github.com/stripe/stripe-go/v84/checkout/session"
"github.com/stripe/stripe-go/v84/customer"
"github.com/stripe/stripe-go/v84/subscription" "github.com/stripe/stripe-go/v84/subscription"
) )
@ -52,7 +53,7 @@ func (c *Client) priceForTier(t pricing.Tier) string {
return c.cfg.StripePriceID return c.cfg.StripePriceID
} }
func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone string, customerCount int) (*stripego.CheckoutSession, error) { func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone string, accountID int64, customerCount int) (*stripego.CheckoutSession, error) {
t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit) t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit)
priceID := c.priceForTier(t) priceID := c.priceForTier(t)
if priceID == "" { if priceID == "" {
@ -76,6 +77,9 @@ func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone stri
if customerPhone != "" { if customerPhone != "" {
params.AddMetadata("customer_phone", customerPhone) params.AddMetadata("customer_phone", customerPhone)
} }
if accountID > 0 {
params.AddMetadata("account_id", strconv.FormatInt(accountID, 10))
}
params.AddMetadata("pricing_tier", strconv.Itoa(int(t))) params.AddMetadata("pricing_tier", strconv.Itoa(int(t)))
return checkoutsession.New(params) return checkoutsession.New(params)
} }
@ -102,6 +106,18 @@ func (c *Client) CreateCheckoutForCustomer(customerID string, customerCount int)
return checkoutsession.New(params) return checkoutsession.New(params)
} }
// CreateCustomer creates a Stripe customer with the given email. Returns the customer ID.
func (c *Client) CreateCustomer(email string) (string, error) {
params := &stripego.CustomerParams{
Email: stripego.String(email),
}
cust, err := customer.New(params)
if err != nil {
return "", err
}
return cust.ID, nil
}
func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) { func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) {
params := &stripego.BillingPortalSessionParams{ params := &stripego.BillingPortalSessionParams{
Customer: stripego.String(customerID), Customer: stripego.String(customerID),

View File

@ -35,10 +35,10 @@ func (c *Client) DeployStack(stackName, username, domain string) error {
} }
data := map[string]any{ data := map[string]any{
"ID": username, "ID": username,
"Subdomain": username, "Subdomain": username,
"Domain": domain, "Domain": domain,
"TraefikNetwork": c.cfg.TraefikNetwork, "TraefikDockerNetwork": c.cfg.TraefikDockerNetwork,
} }
var rendered bytes.Buffer var rendered bytes.Buffer
@ -90,6 +90,34 @@ func (c *Client) RemoveStack(stackName string) error {
return nil return nil
} }
// RemoveStackAndVolumes removes the stack and then any volumes that belonged to it.
func (c *Client) RemoveStackAndVolumes(stackName string) error {
if err := c.RemoveStack(stackName); err != nil {
return err
}
cmd := exec.Command("docker", "volume", "ls", "-q", "--filter", "label=com.docker.stack.namespace="+stackName)
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("warn: list volumes for %s: %s", stackName, strings.TrimSpace(string(output)))
return nil
}
for _, name := range strings.Split(strings.TrimSpace(string(output)), "\n") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
rmCmd := exec.Command("docker", "volume", "rm", name)
rmCmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
if out, err := rmCmd.CombinedOutput(); err != nil {
log.Printf("warn: remove volume %s: %s", name, strings.TrimSpace(string(out)))
} else {
log.Printf("removed volume %s", name)
}
}
return nil
}
func (c *Client) StackExists(stackName string) (bool, error) { func (c *Client) StackExists(stackName string) (bool, error) {
cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}") cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}")
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)

View File

@ -7,18 +7,23 @@
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--bg: #0f1117; --page: #eff8f8;
--surface: #1a1d27; --surface: #ffffff;
--border: #2a2d3a; --navy: #172f43;
--text: #e4e4e7; --border: #d8e7e5;
--muted: #a1a1aa; --text: #14202a;
--accent: #6366f1; --muted: #5c6f77;
--accent-hover: #818cf8; --accent: #75d46b;
--green: #22c55e; --accent-hover: #5fbd55;
--green: #238b4b;
--blue: #276f94;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg); background:
radial-gradient(circle at 18% 12%, rgba(117, 212, 107, 0.18), transparent 28rem),
radial-gradient(circle at 82% 4%, rgba(39, 111, 148, 0.16), transparent 26rem),
linear-gradient(180deg, #e7f5f5 0%, var(--page) 42%, #f8fbf8 100%);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@ -28,25 +33,37 @@
padding: 2rem; padding: 2rem;
} }
.container { max-width: 520px; width: 100%; } .container { max-width: 520px; width: 100%; }
.logo { font-size: 2rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; } .logo {
display: inline-block;
background: var(--navy);
color: #fff;
border-radius: 999px;
padding: 0.55rem 0.9rem;
font-size: 0.95rem;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 1.25rem;
box-shadow: 0 1rem 2.5rem rgba(23, 47, 67, 0.16);
}
.subtitle { font-size: 1.1rem; font-weight: 600; margin-bottom: 2rem; } .subtitle { font-size: 1.1rem; font-weight: 600; margin-bottom: 2rem; }
.subtitle-green { color: var(--green); } .subtitle-green { color: var(--green); }
.subtitle-muted { color: var(--muted); } .subtitle-muted { color: var(--muted); }
.card { .card {
background: var(--surface); background: rgba(255,255,255,0.94);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 24px;
box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
} }
.card h2 { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.75rem; } .card h2 { color: var(--navy); font-size: 1.35rem; font-weight: 850; letter-spacing: -0.035em; margin-bottom: 0.75rem; }
.card p { color: var(--muted); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; } .card p { color: var(--muted); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
.btn { .btn {
display: inline-block; display: inline-block;
background: var(--accent); background: var(--accent);
color: #fff; color: #102414;
border: none; border: none;
border-radius: 8px; border-radius: 999px;
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
@ -57,8 +74,8 @@
.btn:hover { background: var(--accent-hover); } .btn:hover { background: var(--accent-hover); }
.btn-outline { .btn-outline {
background: transparent; background: transparent;
border: 1px solid var(--accent); border: 1px solid var(--blue);
color: var(--accent); color: var(--blue);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 0.9rem; font-size: 0.9rem;
border-radius: 6px; border-radius: 6px;
@ -69,7 +86,7 @@
.btn-outline:disabled { opacity: 0.5; cursor: not-allowed; } .btn-outline:disabled { opacity: 0.5; cursor: not-allowed; }
.resend { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); } .resend { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.resend p { margin-bottom: 0.5rem; font-size: 0.9rem; } .resend p { margin-bottom: 0.5rem; font-size: 0.9rem; }
.resend input { margin-right: 0.5rem; padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); width: 12rem; } .resend input { margin-right: 0.5rem; padding: 0.5rem; border-radius: 10px; border: 1px solid var(--border); background: #fff; color: var(--text); width: 12rem; }
.resend .msg { font-size: 0.85rem; margin-top: 0.5rem; } .resend .msg { font-size: 0.85rem; margin-top: 0.5rem; }
.resend .msg.success { color: var(--green); } .resend .msg.success { color: var(--green); }
.resend .msg.error { color: #ef4444; } .resend .msg.error { color: #ef4444; }
@ -89,8 +106,8 @@
<div class="card"> <div class="card">
<span class="icon">&#128274;</span> <span class="icon">&#128274;</span>
<h2>Sign In First</h2> <h2>Sign In First</h2>
<p>You need to sign in with your new password before activating your stack. If you haven't set your password yet, do that first.</p> <p>You need to sign in with your social account before activating your stack.</p>
<a href="{{.AutheliaURL}}" class="btn">Sign In</a> <a href="{{.IdentityURL}}" class="btn">Sign In</a>
<div class="resend"> <div class="resend">
<p>Didn't get the setup email?</p> <p>Didn't get the setup email?</p>
<form id="resend-form"> <form id="resend-form">

View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>a250.ca - Checkout</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--page: #eff8f8;
--surface: #ffffff;
--navy: #172f43;
--border: #d8e7e5;
--text: #14202a;
--muted: #5c6f77;
--accent: #75d46b;
--accent-hover: #5fbd55;
--blue: #276f94;
}
body {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background:
radial-gradient(circle at 18% 12%, rgba(117, 212, 107, 0.18), transparent 28rem),
radial-gradient(circle at 82% 4%, rgba(39, 111, 148, 0.16), transparent 26rem),
linear-gradient(180deg, #e7f5f5 0%, var(--page) 42%, #f8fbf8 100%);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.container { max-width: 520px; width: 100%; }
.logo {
display: inline-block;
background: var(--navy);
color: #fff;
border-radius: 999px;
padding: 0.55rem 0.9rem;
font-size: 0.95rem;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 1.25rem;
box-shadow: 0 1rem 2.5rem rgba(23, 47, 67, 0.16);
}
.tagline { color: var(--muted); font-size: 1rem; margin-bottom: 1.25rem; line-height: 1.6; }
.card {
background: rgba(255,255,255,0.94);
border: 1px solid var(--border);
border-radius: 24px;
box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
padding: 2rem;
}
.card h2 { color: var(--navy); font-size: 1.6rem; font-weight: 850; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
.price { color: var(--navy); font-size: 2rem; font-weight: 850; margin-bottom: 1.5rem; letter-spacing: -0.04em; }
.price span { font-size: 0.9rem; font-weight: 400; color: var(--muted); }
.account { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
form { display: flex; flex-direction: column; gap: 0.75rem; }
form label {
display: block;
font-size: 0.82rem;
font-weight: 800;
color: var(--navy);
margin-bottom: -0.25rem;
}
input[type="text"], input[type="tel"] {
width: 100%;
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.75rem 1rem;
font-size: 1rem;
color: var(--text);
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus, input[type="tel"]:focus { border-color: var(--blue); }
input[type="text"]::placeholder, input[type="tel"]::placeholder { color: var(--muted); }
button {
background: var(--accent);
color: #102414;
border: none;
border-radius: 999px;
padding: 0.75rem 1rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: var(--accent-hover); }
</style>
{{template "analytics"}}
</head>
<body>
<div class="container">
<div class="logo">a250.ca</div>
<p class="tagline">You are signed in as {{.Email}}. Complete checkout to provision your workspace.</p>
<div class="card">
{{if eq .PricingTier 0}}
<h2>Launch Offer</h2>
<div class="price">$0 <span>/month for max 3 months</span></div>
{{else if eq .PricingTier 1}}
<h2>Founder Plan</h2>
<div class="price">$20 <span>/ year, then $100/month</span></div>
{{else}}
<h2>Pro Plan</h2>
<div class="price">$200 <span>/ month</span></div>
{{end}}
<p class="account">Checkout will use your signed-in Google account: {{.Email}}</p>
<form method="POST" action="/subscribe" id="subscribe-form">
<label for="sub-phone">Phone <span style="font-weight:400;color:var(--muted)">(10+ digits, any format)</span></label>
<input id="sub-phone" type="tel" name="phone" placeholder="e.g. 555 123 4567 or +1 555 123 4567" autocomplete="tel" inputmode="tel" minlength="10" required>
<label for="sub-domain">Domain to manage</label>
<input id="sub-domain" type="text" name="domain" placeholder="e.g. git.mycompany.com" autocomplete="off" required>
<button type="submit">Continue to Stripe</button>
</form>
<script>
(function() {
var phone = document.getElementById('sub-phone');
if (!phone) return;
function digitsOnly(s) { return (s || '').replace(/\D/g, ''); }
function formatPhone(digits) {
if (digits.length <= 3) return digits;
if (digits.length <= 6) return digits.slice(0,3) + ' ' + digits.slice(3);
if (digits.length <= 10) return digits.slice(0,3) + ' ' + digits.slice(3,6) + ' ' + digits.slice(6);
return digits.slice(0,3) + ' ' + digits.slice(3,6) + ' ' + digits.slice(6,10) + ' ' + digits.slice(10,15);
}
phone.addEventListener('input', function() {
var d = digitsOnly(this.value);
if (d.length > 15) d = d.slice(0, 15);
var formatted = formatPhone(d);
if (this.value !== formatted) {
this.value = formatted;
this.setSelectionRange(formatted.length, formatted.length);
}
});
})();
</script>
</div>
</div>
</body>
</html>

View File

@ -7,47 +7,67 @@
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--bg: #0f1117; --page: #eff8f8;
--surface: #1a1d27; --surface: #ffffff;
--border: #2a2d3a; --navy: #172f43;
--text: #e4e4e7; --navy-soft: #24465d;
--muted: #a1a1aa; --border: #d8e7e5;
--accent: #6366f1; --text: #14202a;
--accent-hover: #818cf8; --muted: #5c6f77;
--green: #22c55e; --accent: #75d46b;
--red: #ef4444; --accent-hover: #5fbd55;
--blue: #276f94;
--green: #238b4b;
--red: #b42318;
--warning: #a15c07;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); background:
radial-gradient(circle at 18% 12%, rgba(117, 212, 107, 0.18), transparent 28rem),
radial-gradient(circle at 82% 4%, rgba(39, 111, 148, 0.16), transparent 26rem),
linear-gradient(180deg, #e7f5f5 0%, var(--page) 42%, #f8fbf8 100%);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
padding: 2rem; padding: 1.25rem;
} }
.header { .header {
max-width: 720px; max-width: 980px;
margin: 0 auto 2rem; margin: 0 auto 1.5rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
}
.header .logo { font-size: 1.5rem; font-weight: 800; letter-spacing: -0.04em; }
.header .user-info {
display: flex;
align-items: center;
gap: 1rem; gap: 1rem;
color: var(--muted); background: var(--navy);
font-size: 0.9rem; border-radius: 0 0 14px 14px;
box-shadow: 0 1rem 2.5rem rgba(23, 47, 67, 0.18);
color: #fff;
padding: 0.85rem 1.2rem;
} }
.container { max-width: 720px; margin: 0 auto; } .header .logo { font-size: 1rem; font-weight: 800; letter-spacing: -0.03em; }
.header .user-info {
color: rgba(255,255,255,0.76);
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.container { max-width: 980px; margin: 0 auto; }
.card { .card {
background: var(--surface); background: rgba(255,255,255,0.92);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 22px;
padding: 1.5rem 2rem; box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
margin-bottom: 1.5rem; padding: 1.5rem;
margin-bottom: 1rem;
}
.card h2 {
color: var(--navy);
font-size: 1.25rem;
font-weight: 800;
letter-spacing: -0.035em;
margin-bottom: 1rem;
} }
.card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; }
.status-row { .status-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -55,7 +75,7 @@
padding: 0.5rem 0; padding: 0.5rem 0;
} }
.status-label { color: var(--muted); font-size: 0.9rem; } .status-label { color: var(--muted); font-size: 0.9rem; }
.status-value { font-weight: 600; font-size: 0.9rem; } .status-value { color: var(--navy); font-weight: 700; font-size: 0.9rem; }
.badge { .badge {
display: inline-block; display: inline-block;
padding: 0.2rem 0.7rem; padding: 0.2rem 0.7rem;
@ -63,27 +83,27 @@
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
} }
.badge-active { background: rgba(34,197,94,0.15); color: var(--green); } .badge-active { background: rgba(117,212,107,0.22); color: var(--green); }
.badge-inactive { background: rgba(239,68,68,0.15); color: var(--red); } .badge-inactive { background: rgba(180,35,24,0.1); color: var(--red); }
.stack-link { .stack-link {
display: block; display: block;
background: var(--bg); background: #f6fbfb;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 14px;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
color: var(--accent); color: var(--blue);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
margin-top: 0.75rem; margin-top: 0.75rem;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.stack-link:hover { border-color: var(--accent); } .stack-link:hover { border-color: var(--blue); }
.btn { .btn {
display: inline-block; display: inline-block;
background: var(--accent); background: var(--accent);
color: #fff; color: #102414;
border: none; border: none;
border-radius: 8px; border-radius: 999px;
padding: 0.6rem 1.25rem; padding: 0.6rem 1.25rem;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
@ -95,36 +115,36 @@
.btn-outline { .btn-outline {
background: transparent; background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text); color: var(--navy);
} }
.btn-outline:hover { border-color: var(--accent); color: var(--accent); background: transparent; } .btn-outline:hover { border-color: var(--blue); color: var(--blue); background: #f6fbfb; }
.actions { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; } .actions { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; }
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--muted); } .empty-state { text-align: center; padding: 3rem 1rem; color: var(--muted); }
.empty-state p { margin-bottom: 1.5rem; } .empty-state p { margin-bottom: 1.5rem; }
.btn-danger { .btn-danger {
background: rgba(239,68,68,0.15); background: rgba(180,35,24,0.08);
color: var(--red); color: var(--red);
border: 1px solid rgba(239,68,68,0.3); border: 1px solid rgba(180,35,24,0.2);
} }
.btn-danger:hover { background: rgba(239,68,68,0.25); color: var(--red); } .btn-danger:hover { background: rgba(180,35,24,0.14); color: var(--red); }
.btn-warning { .btn-warning {
background: rgba(234,179,8,0.12); background: rgba(161,92,7,0.08);
color: #eab308; color: var(--warning);
border: 1px solid rgba(234,179,8,0.25); border: 1px solid rgba(161,92,7,0.22);
} }
.btn-warning:hover { background: rgba(234,179,8,0.22); color: #eab308; } .btn-warning:hover { background: rgba(161,92,7,0.14); color: var(--warning); }
.btn-sm { padding: 0.45rem 0.9rem; font-size: 0.82rem; } .btn-sm { padding: 0.45rem 0.9rem; font-size: 0.82rem; }
.divider { border: none; border-top: 1px solid var(--border); margin: 1rem 0; } .divider { border: none; border-top: 1px solid var(--border); margin: 1rem 0; }
.security-notice { .security-notice {
background: rgba(234, 179, 8, 0.08); background: #f6fbfb;
border: 1px solid rgba(234, 179, 8, 0.25); border: 1px solid var(--border);
border-radius: 8px; border-radius: 16px;
padding: 0.85rem 1.1rem; padding: 0.85rem 1.1rem;
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.55; line-height: 1.55;
color: var(--muted); color: var(--muted);
} }
.security-notice strong { color: #eab308; } .security-notice strong { color: var(--navy); }
.version-badge { .version-badge {
position: fixed; position: fixed;
bottom: 0.75rem; bottom: 0.75rem;
@ -171,12 +191,17 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Your Stack</h2> <h2>Your Stack</h2>
{{if .StackError}}
<div class="stack-error" style="background: rgba(180,35,24,0.08); border: 1px solid rgba(180,35,24,0.2); border-radius: 14px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--red);">
{{.StackError}}
</div>
{{end}}
<div class="status-row"> <div class="status-row">
<span class="status-label">Status</span> <span class="status-label">Status</span>
{{if .StackRunning}} {{if .StackRunning}}
<span class="badge badge-active">Running</span> <span class="badge badge-active">Running</span>
{{else if .StackDeployed}} {{else if .StackDeployed}}
<span class="badge" style="background:rgba(234,179,8,0.12);color:#eab308;">Stopped</span> <span class="badge" style="background:rgba(161,92,7,0.1);color:var(--warning);">Stopped</span>
{{else}} {{else}}
<span class="badge badge-inactive">Not deployed</span> <span class="badge badge-inactive">Not deployed</span>
{{end}} {{end}}
@ -189,7 +214,7 @@
{{end}} {{end}}
{{if .StackRunning}} {{if .StackRunning}}
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your dedicated environment is accessible at:</p> <p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your dedicated environment is accessible at:</p>
<a class="stack-link" href="https://{{.Domain}}/i/{{.User}}">{{.Domain}}/i/{{.User}}</a> <a class="stack-link" href="https://{{.Domain}}/i/{{.InstanceSlug}}">{{.Domain}}/i/{{.InstanceSlug}}</a>
{{else if and .StackDeployed (not .StackRunning)}} {{else if and .StackDeployed (not .StackRunning)}}
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your stack is stopped. Start it to access your environment.</p> <p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your stack is stopped. Start it to access your environment.</p>
{{end}} {{end}}
@ -222,6 +247,17 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Manage</h2> <h2>Manage</h2>
{{if .Linked}}
<div class="portal-success" style="background: rgba(117,212,107,0.18); border: 1px solid rgba(117,212,107,0.35); border-radius: 14px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--green);">
You can use Manage Subscription below.
</div>
{{end}}
{{if .PortalError}}
<div class="portal-error" style="background: rgba(180,35,24,0.08); border: 1px solid rgba(180,35,24,0.2); border-radius: 14px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--red);">
{{.PortalError}}
</div>
{{end}}
{{if .CustomerID}}
<div class="actions"> <div class="actions">
{{if and .SubStatus (eq .SubStatus.Label "Expired")}} {{if and .SubStatus (eq .SubStatus.Label "Expired")}}
<form method="POST" action="/resubscribe" style="margin:0"> <form method="POST" action="/resubscribe" style="margin:0">
@ -242,6 +278,12 @@
<p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;"> <p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;">
No refunds for the current billing period. Access continues until the end of your paid month. No refunds for the current billing period. Access continues until the end of your paid month.
</p> </p>
{{else}}
<p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 0.75rem;">Update payment method or cancel from the Stripe portal.</p>
<form method="POST" action="/link-stripe-customer" style="margin:0">
<button type="submit" class="btn btn-sm">Manage Subscription</button>
</form>
{{end}}
</div> </div>
<div class="card"> <div class="card">
<h2>Account Security</h2> <h2>Account Security</h2>
@ -251,8 +293,7 @@
best way to ensure your account is never compromised and used without your knowledge. best way to ensure your account is never compromised and used without your knowledge.
</div> </div>
<div class="actions"> <div class="actions">
<a href="{{.AutheliaURL}}/settings/two-factor-authentication" class="btn btn-outline btn-sm">Set Up Passkey / TOTP</a> <a href="{{.IdentityURL}}" class="btn btn-outline btn-sm">Account Settings</a>
<a href="{{.AutheliaURL}}/settings/security" class="btn btn-outline btn-sm">Change Password</a>
</div> </div>
</div> </div>
{{else if .PaidNotActivated}} {{else if .PaidNotActivated}}
@ -269,11 +310,11 @@
{{if .User}} {{if .User}}
<h2>No Active Subscription</h2> <h2>No Active Subscription</h2>
<p>You're signed in as <strong>{{.User}}</strong>, but you don't have an active subscription.</p> <p>You're signed in as <strong>{{.User}}</strong>, but you don't have an active subscription.</p>
<a href="/" class="btn">Subscribe Now</a> <a href="/checkout" class="btn">Subscribe Now</a>
{{else}} {{else}}
<h2>Sign In Required</h2> <h2>Sign In Required</h2>
<p>Check your email for the password setup link. Once you've set your password and signed in, you can activate your stack here.</p> <p>Check your email for the password setup link. Once you've set your password and signed in, you can activate your stack here.</p>
<a href="{{.AutheliaURL}}" class="btn">Sign In</a> <a href="{{.IdentityURL}}" class="btn">Sign In</a>
{{end}} {{end}}
</div> </div>
</div> </div>

View File

@ -7,17 +7,22 @@
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--bg: #0f1117; --bg: #eff8f8;
--surface: #1a1d27; --surface: #ffffff;
--border: #2a2d3a; --border: #d8e7e5;
--text: #e4e4e7; --text: #14202a;
--muted: #a1a1aa; --muted: #5c6f77;
--accent: #6366f1; --accent: #75d46b;
--accent-hover: #818cf8; --accent-hover: #5fbd55;
--brand: #172f43;
--brand-light: #75d46b;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg); background:
radial-gradient(circle at 18% 12%, rgba(117, 212, 107, 0.18), transparent 28rem),
radial-gradient(circle at 82% 4%, rgba(39, 111, 148, 0.16), transparent 26rem),
linear-gradient(180deg, #e7f5f5 0%, var(--bg) 42%, #f8fbf8 100%);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@ -27,50 +32,89 @@
padding: 2rem; padding: 2rem;
} }
.container { max-width: 480px; width: 100%; } .container { max-width: 480px; width: 100%; }
.logo { .brand {
font-size: 2.5rem; display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
margin-bottom: 1.5rem;
text-align: center;
}
.brand-mark {
display: grid;
place-items: center;
width: 3.5rem;
height: 3.5rem;
color: var(--brand);
font-size: 2rem;
font-weight: 800; font-weight: 800;
letter-spacing: -0.04em; letter-spacing: -0.08em;
margin-bottom: 0.5rem; line-height: 1;
position: relative;
}
.brand-mark::before {
content: "";
position: absolute;
inset: 0.2rem;
border: 2px solid var(--brand-light);
border-radius: 999px;
opacity: 0.55;
}
.logo {
color: var(--brand);
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.03em;
} }
.tagline { .tagline {
color: var(--muted); color: var(--muted);
font-size: 1.1rem; font-size: 0.95rem;
margin-bottom: 2.5rem;
line-height: 1.5; line-height: 1.5;
margin: 0 auto 1.5rem;
max-width: 24rem;
text-align: center;
} }
.card { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 24px;
box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
padding: 2rem; padding: 2rem;
} }
.card h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; } .card h2 { font-size: 1.35rem; font-weight: 700; margin-bottom: 0.5rem; }
.price { font-size: 2rem; font-weight: 700; margin-bottom: 1.5rem; } .price { font-size: 1.8rem; font-weight: 700; margin-bottom: 1.25rem; }
.price span { font-size: 0.9rem; font-weight: 400; color: var(--muted); } .price span { font-size: 0.9rem; font-weight: 400; color: var(--muted); }
.features { list-style: none; margin-bottom: 2rem; } .features { list-style: none; margin-bottom: 2rem; }
.features li { padding: 0.4rem 0; color: var(--muted); font-size: 0.95rem; } .features li { padding: 0.35rem 0; color: var(--muted); font-size: 0.92rem; }
.features li::before { content: "\2713"; color: var(--accent); font-weight: 700; margin-right: 0.75rem; } .features li::before { content: "\2713"; color: var(--brand-light); font-weight: 700; margin-right: 0.75rem; }
form { display: flex; flex-direction: column; gap: 0.75rem; } form { display: flex; flex-direction: column; gap: 0.75rem; }
input[type="email"], input[type="text"] { form label {
background: var(--bg); display: block;
font-size: 0.82rem;
font-weight: 700;
color: var(--text);
margin-bottom: -0.25rem;
}
input[type="email"], input[type="text"], input[type="tel"] {
width: 100%;
background: #fff;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 0;
padding: 0.75rem 1rem; padding: 0.55rem 0.65rem;
font-size: 1rem; font-size: 0.9rem;
color: var(--text); color: var(--text);
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
input[type="email"]:focus, input[type="text"]:focus { border-color: var(--accent); } input[type="email"]:focus, input[type="text"]:focus, input[type="tel"]:focus { border-color: var(--accent); }
input[type="email"]::placeholder, input[type="text"]::placeholder { color: var(--muted); } input[type="email"]::placeholder, input[type="text"]::placeholder, input[type="tel"]::placeholder { color: var(--muted); }
button { button {
background: var(--accent); background: var(--accent);
color: #fff; color: #102414;
border: none; border: none;
border-radius: 8px; border-radius: 2px;
padding: 0.75rem 1rem; padding: 0.65rem 1rem;
font-size: 1rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
@ -79,18 +123,18 @@
.btn-primary { .btn-primary {
display: inline-block; display: inline-block;
background: var(--accent); background: var(--accent);
color: #fff; color: #102414;
text-decoration: none; text-decoration: none;
border-radius: 8px; border-radius: 2px;
padding: 0.75rem 1.5rem; padding: 0.65rem 1.5rem;
font-size: 1rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
transition: background 0.2s; transition: background 0.2s;
text-align: center; text-align: center;
} }
.btn-primary:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; } .footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
.footer a { color: var(--accent); text-decoration: none; } .footer a { color: var(--brand); font-weight: 700; text-decoration: none; }
.version-badge { .version-badge {
position: fixed; position: fixed;
bottom: 0.75rem; bottom: 0.75rem;
@ -107,15 +151,18 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="logo">a250.ca</div>
<p class="tagline">{{.Tagline}}</p>
<div class="card"> <div class="card">
<div class="brand">
<div class="brand-mark">A</div>
<div class="logo">a250.ca</div>
</div>
<p class="tagline">{{.Tagline}}</p>
{{if .SoldOut}} {{if .SoldOut}}
<h2>Signups Full</h2> <h2>Signups Full</h2>
<p style="color: var(--muted); margin-bottom: 1rem;">We've reached our limit for new signups. Check back later.</p> <p style="color: var(--muted); margin-bottom: 1rem;">We've reached our limit for new signups. Check back later.</p>
{{else if and .StripePaymentLink (eq .PricingTier 0) (not .UseCheckoutForm)}} {{else if and .StripePaymentLink (eq .PricingTier 0) (not .UseCheckoutForm)}}
<h2>Free Plan</h2> <h2>Free Plan</h2>
<div class="price">$0 <span>/ one-time</span></div> <div class="price">$0 <span>/month for max 3 months</span></div>
<ul class="features"> <ul class="features">
{{range .Features}}<li>{{.}}</li>{{end}} {{range .Features}}<li>{{.}}</li>{{end}}
</ul> </ul>
@ -123,7 +170,7 @@
{{else if .UseCheckoutForm}} {{else if .UseCheckoutForm}}
{{if eq .PricingTier 0}} {{if eq .PricingTier 0}}
<h2>Launch Offer</h2> <h2>Launch Offer</h2>
<div class="price">$0 <span>/ 3 months, then ends</span></div> <div class="price">$0 <span>/month for max 3 months</span></div>
{{else if eq .PricingTier 1}} {{else if eq .PricingTier 1}}
<h2>Founder Plan</h2> <h2>Founder Plan</h2>
<div class="price">$20 <span>/ year, then $100/month</span></div> <div class="price">$20 <span>/ year, then $100/month</span></div>
@ -134,12 +181,7 @@
<ul class="features"> <ul class="features">
{{range .Features}}<li>{{.}}</li>{{end}} {{range .Features}}<li>{{.}}</li>{{end}}
</ul> </ul>
<form method="POST" action="/subscribe"> <a href="/checkout" class="btn-primary">Continue with Google</a>
<input type="email" name="email" placeholder="you@example.com" required>
<input type="tel" name="phone" placeholder="+1 555 123 4567" autocomplete="tel" required>
<input type="text" name="domain" placeholder="Domain you want to manage (e.g. git.mycompany.com)" autocomplete="off" required>
<button type="submit">Subscribe Now</button>
</form>
{{else}} {{else}}
<h2>Subscribe</h2> <h2>Subscribe</h2>
<p style="color: var(--muted); margin-bottom: 1rem;">Pricing is being configured. Check back soon.</p> <p style="color: var(--muted); margin-bottom: 1rem;">Pricing is being configured. Check back soon.</p>

View File

@ -7,17 +7,22 @@
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--bg: #0f1117; --page: #eff8f8;
--surface: #1a1d27; --surface: #ffffff;
--border: #2a2d3a; --navy: #172f43;
--text: #e4e4e7; --border: #d8e7e5;
--muted: #a1a1aa; --text: #14202a;
--accent: #6366f1; --muted: #5c6f77;
--accent-hover: #818cf8; --accent: #75d46b;
--accent-hover: #5fbd55;
--green: #238b4b;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg); background:
radial-gradient(circle at 18% 12%, rgba(117, 212, 107, 0.18), transparent 28rem),
radial-gradient(circle at 82% 4%, rgba(39, 111, 148, 0.16), transparent 26rem),
linear-gradient(180deg, #e7f5f5 0%, var(--page) 42%, #f8fbf8 100%);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@ -26,15 +31,27 @@
justify-content: center; justify-content: center;
padding: 2rem; padding: 2rem;
} }
.container { max-width: 480px; width: 100%; } .container { max-width: 520px; width: 100%; }
.logo { font-size: 2.5rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; } .logo {
display: inline-block;
background: var(--navy);
color: #fff;
border-radius: 999px;
padding: 0.55rem 0.9rem;
font-size: 0.95rem;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 1.25rem;
box-shadow: 0 1rem 2.5rem rgba(23, 47, 67, 0.16);
}
.card { .card {
background: var(--surface); background: rgba(255,255,255,0.94);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 24px;
box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
padding: 2rem; padding: 2rem;
} }
.card h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; } .card h2 { color: var(--navy); font-size: 1.6rem; font-weight: 850; letter-spacing: -0.04em; margin-bottom: 1rem; }
.card p { color: var(--muted); line-height: 1.6; margin-bottom: 1rem; } .card p { color: var(--muted); line-height: 1.6; margin-bottom: 1rem; }
.card ul { color: var(--muted); line-height: 1.7; margin: 1rem 0; padding-left: 1.25rem; } .card ul { color: var(--muted); line-height: 1.7; margin: 1rem 0; padding-left: 1.25rem; }
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; } .footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
@ -56,6 +73,20 @@
.resend .msg { font-size: 0.85rem; margin-top: 0.5rem; } .resend .msg { font-size: 0.85rem; margin-top: 0.5rem; }
.resend .msg.success { color: var(--green, #22c55e); } .resend .msg.success { color: var(--green, #22c55e); }
.resend .msg.error { color: #ef4444; } .resend .msg.error { color: #ef4444; }
.dashboard-cta { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.dashboard-cta .muted { color: var(--muted); font-size: 0.9rem; margin-bottom: 0.5rem; }
.dashboard-cta .btn-primary {
display: inline-block;
background: var(--accent);
color: #102414;
text-decoration: none;
padding: 0.6rem 1.25rem;
font-size: 0.95rem;
font-weight: 600;
border-radius: 999px;
transition: background 0.2s;
}
.dashboard-cta .btn-primary:hover { background: var(--accent-hover); }
</style> </style>
{{template "analytics"}} {{template "analytics"}}
</head> </head>
@ -63,90 +94,14 @@
<div class="container"> <div class="container">
<div class="logo">a250.ca</div> <div class="logo">a250.ca</div>
<div class="card"> <div class="card">
<h2>Check your inbox</h2> <h2>You're all set</h2>
<p>We've sent a password set email to your address. Use the link in that email to create your password and sign in.</p> <p>Your purchase is complete and your workspace is being prepared.</p>
<p><strong>You'll be required to:</strong></p> <p>Sign in with your social account to open the dashboard and manage your stack.</p>
<ul> <div class="dashboard-cta">
<li>Set a password</li> <p class="muted">Ready to continue?</p>
<li>Enable two-factor authentication or a passkey</li> <a href="{{.AppURL}}/dashboard" class="btn-primary">Go to Dashboard</a>
</ul>
<p>Once you've signed in, you can activate your workspace from the dashboard.</p>
{{if .Username}}
<div class="resend">
<p>Didn't get the email?</p>
<form id="resend-form">
<input type="hidden" name="username" value="{{.Username}}">
<button type="submit" class="btn-outline" id="resend-btn" disabled>Resend (60s)</button>
</form>
<p class="msg" id="resend-msg"></p>
</div> </div>
{{end}}
</div>
<div class="footer">
<a href="{{.AppURL}}/dashboard">Go to Dashboard</a>
</div> </div>
</div> </div>
{{if .Username}}
<script>
(function() {
var form = document.getElementById('resend-form');
var btn = document.getElementById('resend-btn');
var msg = document.getElementById('resend-msg');
var cooldown = 60, interval;
function setCooldown(sec) {
cooldown = sec;
btn.disabled = true;
btn.textContent = 'Resend (' + sec + 's)';
if (interval) clearInterval(interval);
interval = setInterval(function() {
cooldown--;
if (cooldown <= 0) {
clearInterval(interval);
btn.disabled = false;
btn.textContent = 'Resend';
return;
}
btn.textContent = 'Resend (' + cooldown + 's)';
}, 1000);
}
setCooldown(60);
form.addEventListener('submit', function(e) {
e.preventDefault();
btn.disabled = true;
msg.textContent = '';
fetch('/resend-reset', {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function(r) {
return r.json().then(function(data) {
if (data.ok) {
msg.textContent = data.message;
msg.className = 'msg success';
setCooldown(60);
} else if (data.retry_after_seconds) {
msg.textContent = data.error + ' Try again in ' + data.retry_after_seconds + 's.';
msg.className = 'msg error';
setCooldown(data.retry_after_seconds);
} else {
msg.textContent = data.error;
msg.className = 'msg error';
btn.disabled = false;
btn.textContent = 'Resend';
}
});
}).catch(function() {
msg.textContent = 'Something went wrong. Please try again.';
msg.className = 'msg error';
btn.disabled = false;
btn.textContent = 'Resend';
});
});
})();
</script>
{{end}}
</body> </body>
</html> </html>

View File

@ -1,88 +1,42 @@
# ============================================================================= # =============================================================================
# CUSTOMER STACK TEMPLATE — Gitea + PostgreSQL # CUSTOMER STACK TEMPLATE — Uptime Kuma
# ============================================================================= # =============================================================================
# This is the Docker Swarm stack deployed for each paying customer. # Single-service stack for each paying customer. Simple webapp for testing
# It defines what product/service they receive when they subscribe. # routing and auth at https://{{.Domain}}/i/{{.Subdomain}}
# #
# PRODUCT: Gitea — a self-hosted Git service, backed by PostgreSQL. # Traefik: priority 10 ensures /i/{{.Subdomain}} always hits this stack, not
# Each customer gets their own isolated instance at a sub-path. # ss-atlas (priority 1). Strip prefix sends e.g. /i/user/foo -> /foo to the app.
# #
# Structure: # Template variables (injected by swarm/client.go):
# web — the application, exposed via Traefik behind Authelia auth # {{.ID}}, {{.Subdomain}}, {{.Domain}}, {{.TraefikDockerNetwork}}
# db — PostgreSQL, internal only (backend network, never exposed)
#
# To sell a different product: replace the `web` image, update the port
# in the Traefik loadbalancer label, and adjust `db` env/image as needed.
#
# Template variables (injected at deploy time by swarm/client.go):
# {{.ID}} - customer's username (unique resource naming)
# {{.Subdomain}} - customer's username (used in path: /i/{subdomain})
# {{.Domain}} - base domain (e.g. bc.a250.ca)
# {{.TraefikNetwork}} - Traefik overlay network name
#
# Each customer gets their stack at: https://{{.Domain}}/i/{{.Subdomain}}
# Access is restricted to the owning user via Authelia forward-auth.
# ============================================================================= # =============================================================================
services: services:
web: web:
image: gitea/gitea:1-rootless image: louislam/uptime-kuma:2
environment:
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: db:5432
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: gitea
GITEA__server__DOMAIN: "{{.Domain}}"
GITEA__server__ROOT_URL: "https://{{.Domain}}/i/{{.Subdomain}}/"
GITEA__server__HTTP_PORT: "3000"
GITEA__security__INSTALL_LOCK: "true"
volumes: volumes:
- gitea_data:/var/lib/gitea - app_data:/app/data
- gitea_config:/etc/gitea
networks: networks:
- traefik_net - traefik_net
- backend
deploy: deploy:
replicas: 1 replicas: 1
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.docker.network: "atlas_{{.TraefikNetwork}}" traefik.docker.network: "{{.TraefikDockerNetwork}}"
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Domain}}`) && PathPrefix(`/i/{{.Subdomain}}`)" traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Domain}}`) && PathPrefix(`/i/{{.Subdomain}}`)"
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure" traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure"
traefik.http.routers.customer-{{.ID}}-web.priority: "2" traefik.http.routers.customer-{{.ID}}-web.priority: "10"
traefik.http.routers.customer-{{.ID}}-web.tls: "true" traefik.http.routers.customer-{{.ID}}-web.tls: "true"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "strip-customer-{{.ID}}@swarm,authelia-auth@swarm" traefik.http.routers.customer-{{.ID}}-web.middlewares: "strip-customer-{{.ID}}@swarm,authentik@swarm"
traefik.http.middlewares.strip-customer-{{.ID}}.stripprefix.prefixes: "/i/{{.Subdomain}}" traefik.http.middlewares.strip-customer-{{.ID}}.stripprefix.prefixes: "/i/{{.Subdomain}}"
traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3000" traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3001"
restart_policy:
condition: on-failure
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: gitea
POSTGRES_USER: gitea
POSTGRES_PASSWORD: gitea
volumes:
- db_data:/var/lib/postgresql/data
networks:
- backend
deploy:
replicas: 1
restart_policy: restart_policy:
condition: on-failure condition: on-failure
networks: networks:
traefik_net: traefik_net:
external: true external: true
name: "atlas_{{.TraefikNetwork}}" name: "{{.TraefikDockerNetwork}}"
backend:
driver: overlay
volumes: volumes:
gitea_data: app_data:
driver: local
gitea_config:
driver: local
db_data:
driver: local driver: local

52
docs/AUTHENTIK_CUTOVER.md Normal file
View File

@ -0,0 +1,52 @@
# Authentik Cutover
This cutover moves ATLAS customer identity from `LLDAP + Authelia` to
`authentik + Postgres` while preserving Stripe billing and Swarm stacks.
## Existing Customer Import
Before retiring LLDAP, export each customer with:
- current LDAP username
- email
- Stripe customer ID
- customer phone
- customer domain
- current stack name, usually `customer-<slug>`
Insert those records into the new `ss-atlas` Postgres tables:
- `accounts.primary_email`
- `accounts.stripe_customer_id`
- `accounts.phone`
- `accounts.subscription_status = 'active'`
- `instances.slug`
- `instances.stack_name`
- `instances.customer_domain`
Use the existing stack slug when possible so `/i/<slug>` URLs continue to work.
## First Social Login
On first Authentik login, `ss-atlas` links the Authentik identity to an account
by email when no exact provider subject is known yet. After that, the stable
`provider + subject` tuple in `account_identities` owns the login mapping.
## Stripe Reconciliation
Stripe remains the billing source of truth. Webhooks and `/success` both upsert
the same account rows using `stripe_customer_id`, and `billing_events` prevents
reprocessing the same Stripe event.
## Retiring Old Services
Only retire Authelia and LLDAP after:
- all active Stripe customers exist in Postgres
- at least one Authentik identity is linked for each active customer
- `/dashboard`, `/stack-manage`, and `/i/<slug>` work through Authentik
- subscription cancellation archives/removes the correct stack
Keep a database snapshot and Swarm volume backup before deleting old identity
volumes.

View File

@ -1,452 +1,40 @@
#!/bin/sh #!/bin/sh
# Woodpecker production deploy for the Authentik-backed ATLAS stack.
################################################################################ set -eu
# WOODPECKER CI PRODUCTION DEPLOYMENT SCRIPT
################################################################################
#
# ⚠️ WARNING: THIS SCRIPT IS EXCLUSIVELY FOR WOODPECKER CI USE
#
# This script is designed to run within the Woodpecker CI environment with
# specific environment variables and Docker socket access.
#
# 🚫 DO NOT RUN THIS ON A DEVELOPER WORKSTATION
# 🚫 This will attempt to remove production Docker stacks and secrets
# 🚫 This requires access to production Docker swarm manager nodes
#
# This script handles:
# - Production stack removal and cleanup
# - Docker secrets recreation with fresh values
# - New stack deployment with verification
# - Health checking and deployment validation
# - Rollback capability on failure
# - Concurrent execution prevention
#
################################################################################
set -euo pipefail STACK_NAME="${STACK_NAME:-atlas}"
STACK_FILE="${STACK_FILE:-stack.production.yml}"
SS_ATLAS_IMAGE="${SS_ATLAS_IMAGE:-git.nixc.us/a250/ss-atlas:production}"
# Configuration
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MAX_RETRIES=3
RETRY_DELAY=5
FORCE_PULL=true # Always pull latest images
DEPLOYMENT_TIMEOUT=180 # Reduced from 300s to 180s (3 minutes)
HEALTH_CHECK_TIMEOUT=90 # Reduced from 120s to 90s
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Global variables for cleanup
DEPLOYMENT_STARTED=false
OLD_IMAGE_HASH=""
NEW_IMAGE_HASH=""
ROLLBACK_NEEDED=false
# Logging functions
log() { log() {
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')] $1${NC}" printf '[ci-deploy] %s\n' "$*"
} }
error() { fail() {
echo -e "${RED}[ERROR] $1${NC}" printf '[ci-deploy] ERROR: %s\n' "$*" >&2
exit 1
} }
success() { [ -f "$STACK_FILE" ] || fail "Missing $STACK_FILE"
echo -e "${GREEN}[SUCCESS] $1${NC}" docker info >/dev/null 2>&1 || fail "Docker daemon is not reachable"
} [ "$(docker info --format '{{.Swarm.LocalNodeState}}')" = "active" ] || fail "Docker is not an active swarm manager"
warning() { if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
echo -e "${YELLOW}[WARNING] $1${NC}" log "Logging into git.nixc.us"
} printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.nixc.us
fi
debug() { log "Pulling $SS_ATLAS_IMAGE"
echo -e "${PURPLE}[DEBUG] $1${NC}" docker pull "$SS_ATLAS_IMAGE"
}
# Cleanup function - runs on script exit log "Deploying $STACK_NAME from $STACK_FILE"
cleanup() { docker stack deploy --with-registry-auth -c "$STACK_FILE" "$STACK_NAME"
local exit_code=$?
debug "Script completed with exit code: $exit_code"
exit $exit_code
}
# Set up cleanup trap if docker service inspect "${STACK_NAME}_ss-atlas" >/dev/null 2>&1; then
trap cleanup EXIT INT TERM log "Forcing ${STACK_NAME}_ss-atlas to $SS_ATLAS_IMAGE"
docker service update --force --image "$SS_ATLAS_IMAGE" "${STACK_NAME}_ss-atlas"
fi
# Retry function for operations that might fail transiently log "Current stack tasks"
retry_command() { docker stack ps "$STACK_NAME" --no-trunc
local cmd="$1"
local description="$2"
local attempt=1
while [ $attempt -le $MAX_RETRIES ]; do
log "Attempt $attempt/$MAX_RETRIES: $description"
if eval "$cmd"; then
success "$description completed successfully"
return 0
else
if [ $attempt -eq $MAX_RETRIES ]; then
error "$description failed after $MAX_RETRIES attempts"
return 1
else
warning "$description failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
fi
fi
attempt=$((attempt + 1))
done
}
# Pre-flight checks
pre_flight_checks() {
log "Running pre-flight checks..."
# Verify we're running in CI environment
if [ -z "${CI_REPO_NAME:-}" ]; then
error "This script must only be run in Woodpecker CI environment!"
error "Missing CI_REPO_NAME environment variable"
exit 1
fi
# Check Docker daemon is responsive
if ! docker info >/dev/null 2>&1; then
error "Docker daemon is not responsive"
exit 1
fi
# Verify required environment variables (OIDC secrets temporarily disabled)
REQUIRED_VARS="REGISTRY_USER REGISTRY_PASSWORD CI_REPO_NAME AUTHENTICATION_BACKEND_LDAP_PASSWORD IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET STORAGE_ENCRYPTION_KEY SESSION_SECRET NOTIFIER_SMTP_PASSWORD"
for var in $REQUIRED_VARS; do
eval "var_value=\$$var"
if [ -z "$var_value" ]; then
error "Required environment variable $var is not set"
exit 1
fi
done
# Check if stack file exists
if [ ! -f "./stack.production.yml" ]; then
error "Production stack file not found: ./stack.production.yml"
exit 1
fi
success "Pre-flight checks completed"
}
# Get current image ID for rollback purposes
get_current_image_id() {
if docker stack ps "${CI_REPO_NAME}" >/dev/null 2>&1; then
OLD_IMAGE_HASH=$(docker stack ps "${CI_REPO_NAME}" --format "table {{.Image}}" | grep authelia | head -n1 || echo "")
if [ -n "$OLD_IMAGE_HASH" ]; then
debug "Current image for rollback: $OLD_IMAGE_HASH"
fi
fi
}
# Rollback function
attempt_rollback() {
if [ -n "$OLD_IMAGE_HASH" ] && [ "$OLD_IMAGE_HASH" != "IMAGE" ]; then
warning "Attempting rollback to previous image: $OLD_IMAGE_HASH"
# This would require a more complex rollback mechanism
# For now, just log the attempt
error "Rollback mechanism not yet implemented"
error "Manual intervention required"
error "Previous image was: $OLD_IMAGE_HASH"
else
error "No previous image information available for rollback"
fi
}
# Enhanced Docker registry login with retries
docker_registry_login() {
log "Logging into Docker registry"
local login_cmd="echo '${REGISTRY_PASSWORD}' | docker login -u '${REGISTRY_USER}' --password-stdin git.nixc.us"
retry_command "$login_cmd" "Docker registry login"
}
# Force pull latest images to ensure we deploy the newest version
force_pull_latest_images() {
log "🚀 Force pulling latest images to ensure fresh deployment"
# Get the image names from docker-compose production file
local authelia_image="git.nixc.us/a250/authelia:production-authelia"
local mariadb_image="git.nixc.us/a250/authelia:production-mariadb"
local redis_image="git.nixc.us/a250/authelia:production-redis"
# Pull each image and capture new hashes
log "Pulling Authelia image..."
if docker pull "$authelia_image"; then
NEW_IMAGE_HASH=$(docker images --format "table {{.Repository}}:{{.Tag}}\t{{.ID}}" | grep "production-authelia" | awk '{print $2}' | head -n1)
success "✅ Authelia image pulled: $NEW_IMAGE_HASH"
else
error "❌ Failed to pull Authelia image"
return 1
fi
log "Pulling MariaDB image..."
retry_command "docker pull $mariadb_image" "MariaDB image pull"
log "Pulling Redis image..."
retry_command "docker pull $redis_image" "Redis image pull"
# Verify we have a new image hash
if [ -n "$NEW_IMAGE_HASH" ] && [ "$NEW_IMAGE_HASH" != "$OLD_IMAGE_HASH" ]; then
success "🔄 New image detected: $OLD_IMAGE_HASH$NEW_IMAGE_HASH"
elif [ -n "$NEW_IMAGE_HASH" ]; then
warning "⚠️ Same image hash detected: $NEW_IMAGE_HASH (this may be expected)"
else
error "❌ Could not determine new image hash"
return 1
fi
}
# Get detailed container information for debugging
get_container_diagnostics() {
local service_name="$1"
local container_logs=""
error "=== 🔍 DETAILED DIAGNOSTICS FOR ${service_name} ==="
# Get all tasks for this service
local tasks
tasks=$(docker service ps "${CI_REPO_NAME}_${service_name}" --format "{{.ID}}\t{{.Name}}\t{{.CurrentState}}\t{{.Error}}" --no-trunc)
if [ -n "$tasks" ]; then
error "Service tasks:"
echo "$tasks" | while IFS=$'\t' read -r task_id name state task_error; do
error " Task: $name"
error " ID: $task_id"
error " State: $state"
if [ -n "$task_error" ]; then
error " Error: $task_error"
fi
# Try to get container logs for this task
log "Attempting to get logs for task $task_id..."
local task_logs
task_logs=$(docker service logs "${CI_REPO_NAME}_${service_name}" --raw --tail 20 2>/dev/null || echo "No logs available")
if [ "$task_logs" != "No logs available" ]; then
error " Recent logs:"
echo "$task_logs" | sed 's/^/ /'
fi
done
else
error "No service tasks found for ${service_name}"
fi
# Get service inspection details
error "Service inspection:"
docker service inspect "${CI_REPO_NAME}_${service_name}" --pretty 2>/dev/null | head -20 | sed 's/^/ /' || error " Service inspect failed"
# Check if there are any containers running for this service
local containers
containers=$(docker ps -a --filter "label=com.docker.swarm.service.name=${CI_REPO_NAME}_${service_name}" --format "{{.ID}}\t{{.Status}}\t{{.Names}}" 2>/dev/null || echo "")
if [ -n "$containers" ]; then
error "Associated containers:"
echo "$containers" | while IFS=$'\t' read -r container_id status name; do
error " Container: $name ($container_id)"
error " Status: $status"
# Get container logs
local container_logs
container_logs=$(docker logs "$container_id" --tail 15 2>&1 || echo "No container logs available")
error " Container logs (last 15 lines):"
echo "$container_logs" | sed 's/^/ /'
done
else
error "No containers found for service ${service_name}"
fi
error "=== END DIAGNOSTICS FOR ${service_name} ==="
}
# Optimized wait for stack removal
wait_for_stack_removal() {
log "Verifying stack removal completed"
local timeout=60 # Reduced timeout for faster deployment
local elapsed=0
while docker stack ls | grep -q "${CI_REPO_NAME}"; do
if [ $elapsed -ge $timeout ]; then
error "Stack removal timeout after ${timeout} seconds"
return 1
fi
if [ $((elapsed % 10)) -eq 0 ]; then # Log every 10 seconds instead of 5
log "Stack still exists, waiting... (${elapsed}s/${timeout}s)"
fi
sleep 2 # Check every 2 seconds instead of 5
elapsed=$((elapsed + 2))
done
success "Stack removal completed in ${elapsed} seconds"
}
# Enhanced secret management with validation
manage_secrets() {
log "Managing Docker secrets"
# List of secrets (OIDC secrets temporarily disabled)
SECRETS="AUTHENTICATION_BACKEND_LDAP_PASSWORD IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET STORAGE_ENCRYPTION_KEY SESSION_SECRET NOTIFIER_SMTP_PASSWORD"
# Remove old secrets
log "Removing old Docker secrets"
for secret in $SECRETS; do
if docker secret ls --format "{{.Name}}" | grep -q "^${secret}$"; then
docker secret rm "$secret" || true
debug "Removed secret: $secret"
else
debug "Secret $secret did not exist"
fi
done
# Create new secrets with validation
log "Creating new Docker secrets with updated values"
for secret in $SECRETS; do
# Use eval for indirect variable access in POSIX shell
eval "secret_value=\$$secret"
if [ -n "$secret_value" ]; then
if echo "$secret_value" | docker secret create "$secret" -; then
success "Created secret: $secret"
else
error "Failed to create secret: $secret"
return 1
fi
else
error "Environment variable $secret is not set!"
return 1
fi
done
# Verify all secrets were created
log "Verifying secret creation"
for secret in $SECRETS; do
if ! docker secret ls --format "{{.Name}}" | grep -q "^${secret}$"; then
error "Secret verification failed: $secret was not created"
return 1
fi
done
success "All secrets created and verified"
}
# Enhanced deployment with better error handling
deploy_stack() {
log "Deploying new stack with fresh secrets"
DEPLOYMENT_STARTED=true
local deploy_cmd="docker stack deploy --with-registry-auth -c ./stack.production.yml '${CI_REPO_NAME}'"
if ! retry_command "$deploy_cmd" "Stack deployment"; then
error "Stack deployment failed"
return 1
fi
success "Stack deployment command completed"
}
# Simple deployment verification - just deploy and get logs if it fails
comprehensive_health_check() {
log "🔍 Waiting for services to start..."
# Wait for database initialization
log "Waiting 60 seconds for database initialization..."
sleep 60
# Check deployment status
log "Final deployment status:"
docker stack ps "${CI_REPO_NAME}"
# Get logs for any failed services
log "Checking for failures and getting logs..."
# Check Authelia
if docker stack ps "${CI_REPO_NAME}" | grep "authelia_authelia" | grep -q "Failed"; then
error "❌ Authelia service failed - getting logs:"
docker service logs "${CI_REPO_NAME}_authelia" --tail 30 2>/dev/null || echo "No logs available"
elif docker stack ps "${CI_REPO_NAME}" | grep "authelia_authelia" | grep -q "Running"; then
success "✅ Authelia service is running"
else
warning "⚠️ Authelia service status unclear - getting logs:"
docker service logs "${CI_REPO_NAME}_authelia" --tail 20 2>/dev/null || echo "No logs available"
fi
# Check MariaDB
if docker stack ps "${CI_REPO_NAME}" | grep "authelia_mariadb" | grep -q "Failed"; then
error "❌ MariaDB service failed - getting logs:"
docker service logs "${CI_REPO_NAME}_mariadb" --tail 20 2>/dev/null || echo "No logs available"
elif docker stack ps "${CI_REPO_NAME}" | grep "authelia_mariadb" | grep -q "Running"; then
success "✅ MariaDB service is running"
fi
# Check Redis
if docker stack ps "${CI_REPO_NAME}" | grep "authelia_redis" | grep -q "Running"; then
success "✅ Redis service is running"
fi
log "Deployment completed - check logs above for any issues"
return 0
}
# Remove all unused images (old stack versions) from the node
prune_old_images() {
log "Pruning unused images (removing old versions)"
docker image prune -a -f || warning "Image prune had non-fatal issues"
success "Old image versions pruned"
}
# Main deployment function
main() {
log "🚀 Starting production deployment for ${CI_REPO_NAME}"
# Pre-flight checks
pre_flight_checks
# Get current state for potential rollback
get_current_image_id
# Step 1: Docker registry login
docker_registry_login
# Step 1.5: Force pull latest images to ensure fresh deployment
force_pull_latest_images
# Step 2: Remove old stack to release secrets
log "Removing old stack to release secrets"
docker stack rm "${CI_REPO_NAME}" || true
# Step 3: Wait for complete stack removal with optimized timeout
log "Waiting for complete stack removal (minimum 15 seconds)"
sleep 15 # Reduced from 30 seconds
wait_for_stack_removal
# Step 4 & 5: Manage secrets (remove old, create new)
manage_secrets
# Step 6: Deploy new stack
deploy_stack
# Step 7-9: Rapid health checking with container diagnostics
comprehensive_health_check
# Step 10: Remove old image versions from the node
prune_old_images
success "🎉 Production deployment completed successfully!"
success "🏆 Deployed image: $NEW_IMAGE_HASH"
}
# Run main function
main "$@"

150
ship.sh Executable file
View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Build the production images on the target Docker context and deploy app.a250.ca.
set -Eeuo pipefail
DOMAIN="${DOMAIN:-app.a250.ca}"
STACK_NAME="${STACK_NAME:-atlas}"
DEPLOY_CONTEXT="${DOCKER_DEPLOY_CONTEXT:-${DEPLOY_CONTEXT:-}}"
DEPLOY_HOST="${DEPLOY_HOST:-app.a250.ca}"
STACK_FILE="${STACK_FILE:-stack.production.yml}"
SKIP_TESTS="${SKIP_TESTS:-1}"
SKIP_HEALTHCHECK="${SKIP_HEALTHCHECK:-1}"
PUSH_IMAGES="${PUSH_IMAGES:-1}"
REGISTRY_IMAGE="${REGISTRY_IMAGE:-git.nixc.us/a250/ss-atlas}"
SS_ATLAS_LOCAL_IMAGE="${SS_ATLAS_LOCAL_IMAGE:-atlas-ss-atlas:production}"
SS_ATLAS_PUSH_IMAGE="${SS_ATLAS_PUSH_IMAGE:-$REGISTRY_IMAGE:production}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
log() {
printf '[ship] %s\n' "$*"
}
fail() {
printf '[ship] ERROR: %s\n' "$*" >&2
exit 1
}
run() {
log "+ $*"
"$@"
}
run_docker() {
log "+ docker ${DOCKER_LABEL} $*"
docker "${DOCKER_ARGS[@]}" "$@"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
docker_target() {
docker "${DOCKER_ARGS[@]}" "$@"
}
service_exists() {
docker_target service inspect "$1" >/dev/null 2>&1
}
force_service_image() {
local service="$1"
local image="$2"
if service_exists "$service"; then
run_docker service update --force --image "$image" "$service"
else
log "Service $service is not present yet; stack deploy will create it."
fi
}
cd "$ROOT_DIR"
require_command git
require_command docker
if [ -z "$DEPLOY_CONTEXT" ]; then
if docker context inspect macmini7 >/dev/null 2>&1; then
DEPLOY_CONTEXT="macmini7"
else
DEPLOY_CONTEXT=""
fi
fi
if [ -n "$DEPLOY_CONTEXT" ]; then
DOCKER_ARGS=(--context "$DEPLOY_CONTEXT")
DOCKER_LABEL="--context $DEPLOY_CONTEXT"
else
DOCKER_ARGS=(-H "ssh://$DEPLOY_HOST")
DOCKER_LABEL="-H ssh://$DEPLOY_HOST"
fi
[ -f "$STACK_FILE" ] || fail "Missing $STACK_FILE"
grep -q "$DOMAIN" "$STACK_FILE" || fail "$STACK_FILE does not reference $DOMAIN"
if ! docker_target info >/dev/null 2>&1; then
fail "Docker target '${DOCKER_LABEL}' is not reachable"
fi
if [ "$(docker_target info --format '{{.Swarm.LocalNodeState}}')" != "active" ]; then
fail "Docker target '${DOCKER_LABEL}' is not an active swarm manager"
fi
BUILD_COMMIT="$(git rev-parse --short HEAD)"
if [ -n "$(git status --porcelain)" ]; then
BUILD_COMMIT="${BUILD_COMMIT}-dirty"
log "Working tree has uncommitted changes; shipping current checkout as $BUILD_COMMIT."
fi
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
log "Shipping $BUILD_COMMIT to https://$DOMAIN via docker ${DOCKER_LABEL}."
if [ "$SKIP_TESTS" != "1" ]; then
require_command go
log "+ (cd docker/ss-atlas && go test ./...)"
(cd docker/ss-atlas && go test ./...)
else
log "Skipping tests because SKIP_TESTS=1."
fi
run_docker build \
--pull \
--no-cache \
--build-arg "BUILD_COMMIT=$BUILD_COMMIT" \
--build-arg "BUILD_TIME=$BUILD_TIME" \
--label "org.opencontainers.image.revision=$BUILD_COMMIT" \
--label "org.opencontainers.image.created=$BUILD_TIME" \
-t "$SS_ATLAS_LOCAL_IMAGE" \
-t "$SS_ATLAS_PUSH_IMAGE" \
-f docker/ss-atlas/Dockerfile \
docker/ss-atlas
if [ "$PUSH_IMAGES" = "1" ]; then
run_docker push "$SS_ATLAS_PUSH_IMAGE"
else
log "Skipping image pushes because PUSH_IMAGES=0."
fi
run_docker stack deploy \
--with-registry-auth \
--resolve-image never \
-c "$STACK_FILE" \
"$STACK_NAME"
force_service_image "${STACK_NAME}_ss-atlas" "$SS_ATLAS_PUSH_IMAGE"
log "Current stack tasks:"
run_docker stack ps "$STACK_NAME" --no-trunc
if [ "$SKIP_HEALTHCHECK" != "1" ]; then
require_command curl
run curl -fsS "https://$DOMAIN/health"
printf '\n'
run curl -fsS "https://$DOMAIN/version"
printf '\n'
else
log "Skipping health checks because SKIP_HEALTHCHECK=1."
fi
log "Done. Requested $BUILD_COMMIT on https://$DOMAIN."

View File

@ -1,209 +1,190 @@
x-authelia-env: &authelia-env services:
X_AUTHELIA_EMAIL: authelia@a250.ca atlas-postgres:
X_AUTHELIA_SITE_NAME: ATLAS image: postgres:16-alpine
X_AUTHELIA_CONFIG_FILTERS: template environment:
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca POSTGRES_DB: atlas
TRAEFIK_DOMAIN: bc.a250.ca POSTGRES_USER: atlas
POSTGRES_PASSWORD: atlas
volumes:
- atlas_postgres_data:/var/lib/postgresql/data
networks:
- atlas_internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U atlas -d atlas"]
start_period: 10s
interval: 30s
timeout: 5s
retries: 5
secrets: authentik-postgres:
AUTHENTICATION_BACKEND_LDAP_PASSWORD: image: postgres:16-alpine
external: true environment:
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET: POSTGRES_DB: authentik
external: true POSTGRES_USER: authentik
# TEMPORARILY DISABLED - OIDC provider disabled POSTGRES_PASSWORD: authentik
# IDENTITY_PROVIDERS_OIDC_HMAC_SECRET: volumes:
# external: true - authentik_postgres_data:/var/lib/postgresql/data
# IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY: networks:
# external: true - atlas_internal
# IDENTITY_PROVIDERS_OIDC_JWKS_KEY: healthcheck:
# external: true test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
NOTIFIER_SMTP_PASSWORD: start_period: 10s
external: true interval: 30s
SESSION_SECRET: timeout: 5s
external: true retries: 5
STORAGE_ENCRYPTION_KEY:
external: true authentik-redis:
# TEMPORARILY DISABLED - OAuth clients disabled image: redis:7-alpine
# CLIENT_SECRET_HEADSCALE: command: redis-server --save 60 1 --loglevel warning
# external: true volumes:
# CLIENT_SECRET_HEADADMIN: - authentik_redis_data:/data
# external: true networks:
# CLIENT_SECRET_PORTAINER: - atlas_internal
# external: true
# TEMPORARILY DISABLED - Gitea OAuth (not ready yet) authentik-server:
# CLIENT_SECRET_GITEA: image: ghcr.io/goauthentik/server:latest
# external: true command: server
environment:
AUTHENTIK_SECRET_KEY: change-me-before-production
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: authentik
AUTHENTIK_BOOTSTRAP_PASSWORD: change-me-before-production
AUTHENTIK_BOOTSTRAP_TOKEN: change-me-before-production
AUTHENTIK_BOOTSTRAP_EMAIL: admin@a250.ca
volumes:
- authentik_media:/media
- authentik_templates:/templates
networks:
- atlas_internal
- traefik
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.middlewares.authentik.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid"
- "traefik.http.routers.authentik.rule=Host(`app.a250.ca`) && (PathPrefix(`/outpost.goauthentik.io/`) || PathPrefix(`/if/`) || PathPrefix(`/flows/`) || PathPrefix(`/application/`) || PathPrefix(`/source/`) || PathPrefix(`/api/`) || PathPrefix(`/static/`) || PathPrefix(`/media/`) || PathPrefix(`/ws/`))"
- "traefik.http.routers.authentik.entrypoints=websecure"
- "traefik.http.routers.authentik.tls=true"
- "traefik.http.routers.authentik.tls.certresolver=letsencryptresolver"
- "traefik.http.routers.authentik.priority=100"
- "traefik.http.routers.authentik-oauth.rule=Host(`app.a250.ca`) && (PathPrefix(`/flows/`) || PathPrefix(`/application/`) || PathPrefix(`/source/`) || PathPrefix(`/api/`) || PathPrefix(`/static/`) || PathPrefix(`/media/`) || PathPrefix(`/ws/`))"
- "traefik.http.routers.authentik-oauth.entrypoints=websecure"
- "traefik.http.routers.authentik-oauth.tls=true"
- "traefik.http.routers.authentik-oauth.tls.certresolver=letsencryptresolver"
- "traefik.http.routers.authentik-oauth.priority=200"
- "traefik.http.routers.authentik-oauth.service=authentik"
- "traefik.http.services.authentik.loadbalancer.server.port=9000"
authentik-worker:
image: ghcr.io/goauthentik/server:latest
command: worker
environment:
AUTHENTIK_SECRET_KEY: change-me-before-production
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: authentik
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- authentik_media:/media
- authentik_templates:/templates
networks:
- atlas_internal
ss-atlas:
image: git.nixc.us/a250/ss-atlas:production
environment:
- STRIPE_SECRET_KEY=sk_test_51T6uRBRfasa3uSsu1EwvRHaGKhWopjeBz15aDACaI3ectJ1przHIKTX2DAqJu7DDtsBMhIuRiyVf0MY9ivtUvzk800kEZ5advL
- STRIPE_WEBHOOK_SECRET=whsec_placeholder
- STRIPE_PRICE_ID=price_1T6v8dRfasa3uSsuCWmIC0Fn
- STRIPE_PRICE_ID_FREE=price_1T7NOURfasa3uSsuEpbKAD1h
- STRIPE_PRICE_ID_YEAR=price_1T7NOURfasa3uSsu3fB9ivyn
- STRIPE_PRICE_ID_MONTH_100=price_1T7NOVRfasa3uSsuEaxzMNno
- STRIPE_PRICE_ID_MONTH_200=price_1T7NOVRfasa3uSsucQRRlPCi
- STRIPE_PAYMENT_LINK=
- FREE_TIER_LIMIT=10
- YEAR_TIER_LIMIT=50
- MAX_SIGNUPS=0
- DOCKER_HOST=unix:///var/run/docker.sock
- APP_URL=https://app.a250.ca
- IDENTITY_URL=https://app.a250.ca/if/user/
- DATABASE_URL=postgres://atlas:atlas@atlas-postgres:5432/atlas?sslmode=disable
- TRAEFIK_DOMAIN=app.a250.ca
- TRAEFIK_NETWORK=traefik
- TRAEFIK_DOCKER_NETWORK=traefik
- CUSTOMER_DOMAIN=app.a250.ca
- TEMPLATE_PATH=/app/templates
- ARCHIVE_PATH=/archives
- LANDING_TAGLINE=Your own workspace, ready in minutes.
- LANDING_FEATURES=Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime
- ADMIN_SECRET=
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- atlas_archives:/archives
networks:
- atlas_internal
- traefik
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.ss-atlas.rule=Host(`app.a250.ca`)"
- "traefik.http.routers.ss-atlas.entrypoints=websecure"
- "traefik.http.routers.ss-atlas.tls=true"
- "traefik.http.routers.ss-atlas.tls.certresolver=letsencryptresolver"
- "traefik.http.routers.ss-atlas.priority=1"
- "traefik.http.routers.ss-atlas.service=ss-atlas"
- "traefik.http.routers.ss-atlas-protected.rule=Host(`app.a250.ca`) && (PathPrefix(`/checkout`) || PathPrefix(`/subscribe`) || PathPrefix(`/activate`) || PathPrefix(`/dashboard`) || PathPrefix(`/link-stripe-customer`) || PathPrefix(`/portal`) || PathPrefix(`/resubscribe`) || PathPrefix(`/stack-manage`))"
- "traefik.http.routers.ss-atlas-protected.entrypoints=websecure"
- "traefik.http.routers.ss-atlas-protected.tls=true"
- "traefik.http.routers.ss-atlas-protected.tls.certresolver=letsencryptresolver"
- "traefik.http.routers.ss-atlas-protected.priority=20"
- "traefik.http.routers.ss-atlas-protected.middlewares=authentik@swarm"
- "traefik.http.routers.ss-atlas-protected.service=ss-atlas"
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
- "traefik.http.routers.ss-atlas-instance.rule=Host(`app.a250.ca`) && PathPrefix(`/i/`)"
- "traefik.http.routers.ss-atlas-instance.entrypoints=websecure"
- "traefik.http.routers.ss-atlas-instance.tls=true"
- "traefik.http.routers.ss-atlas-instance.tls.certresolver=letsencryptresolver"
- "traefik.http.routers.ss-atlas-instance.priority=15"
- "traefik.http.routers.ss-atlas-instance.middlewares=authentik@swarm"
- "traefik.http.routers.ss-atlas-instance.service=ss-atlas"
whoami:
image: traefik/whoami
networks:
- atlas_internal
- traefik
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.whoami.rule=Host(`app.a250.ca`) && PathPrefix(`/whoami`)"
- "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.tls=true"
- "traefik.http.routers.whoami.tls.certresolver=letsencryptresolver"
- "traefik.http.routers.whoami.middlewares=strip-whoami@swarm,authentik@swarm"
- "traefik.http.middlewares.strip-whoami.stripprefix.prefixes=/whoami"
- "traefik.http.services.whoami.loadbalancer.server.port=80"
networks: networks:
default: atlas_internal:
driver: overlay driver: overlay
attachable: true
traefik: traefik:
external: true external: true
ad:
external: true
volumes: volumes:
authelia_config: atlas_archives:
driver: local atlas_postgres_data:
authelia_assets: authentik_postgres_data:
driver: local authentik_redis_data:
authelia_redis_data: authentik_media:
driver: local authentik_templates:
authelia_mariadb_data:
driver: local
lldap_data:
driver: local
services:
authelia:
image: git.nixc.us/a250/authelia:production-authelia
command:
- authelia
- --config=/config/configuration.server.yml
- --config=/config/configuration.ldap.yml
- --config=/config/configuration.acl.yml
- --config=/config/configuration.notifier.yml
secrets:
- AUTHENTICATION_BACKEND_LDAP_PASSWORD
- IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
# - IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
# - IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
# - IDENTITY_PROVIDERS_OIDC_JWKS_KEY
- NOTIFIER_SMTP_PASSWORD
- SESSION_SECRET
- STORAGE_ENCRYPTION_KEY
# - CLIENT_SECRET_HEADSCALE
# - CLIENT_SECRET_HEADADMIN
# - CLIENT_SECRET_PORTAINER
environment: *authelia-env
dns:
- 1.1.1.1 # Cloudflare
- 9.9.9.9 # Quad9
volumes:
# - authelia_config:/config:rw
- authelia_assets:/config/assets:rw
networks:
- traefik
- default
- ad
deploy:
update_config:
order: start-first
failure_action: rollback
parallelism: 1
restart_policy:
condition: on-failure
replicas: 1
labels:
us.a250.autodeploy: "true"
homepage.group: Infrastructure
homepage.name: Authelia
homepage.href: https://login.bc.a250.ca
homepage.description: ATLAS
traefik.enable: "true"
traefik.docker.network: traefik
traefik.http.routers.authelia_authelia.rule: Host(`login.bc.a250.ca`)
traefik.http.routers.authelia_authelia.entrypoints: web
traefik.http.routers.authelia_authelia.service: authelia_authelia
traefik.http.services.authelia_authelia.loadbalancer.server.port: 9091
traefik.http.middlewares.authelia_authelia.forwardauth.address: http://authelia_authelia:9091/api/verify?rd=https://login.bc.a250.ca/
traefik.http.middlewares.authelia_authelia.forwardauth.trustForwardHeader: "true"
traefik.http.middlewares.authelia_authelia.forwardauth.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email
traefik.http.middlewares.authelia-basic.forwardauth.address: http://authelia_authelia:9091/api/verify?auth=basic
traefik.http.middlewares.authelia-basic.forwardauth.trustForwardHeader: "true"
traefik.http.middlewares.authelia-basic.forwardauth.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email
# healthcheck:
# test: ["CMD", "nc", "-z", "localhost", "9091"]
# start_period: 30s
# interval: 30s
# timeout: 10s
# retries: 3
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
redis:
image: git.nixc.us/a250/authelia:production-redis
command: redis-server --appendonly yes
volumes:
- authelia_redis_data:/data:rw
networks:
- default
deploy:
update_config:
order: start-first
failure_action: rollback
parallelism: 1
restart_policy:
condition: on-failure
replicas: 1
labels:
us.a250.autodeploy: "true"
traefik.enable: "false"
# healthcheck:
# test: ["CMD", "redis-cli", "ping"]
# start_period: 10s
# interval: 30s
# timeout: 5s
# retries: 3
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
lldap:
image: nitnelave/lldap:latest
volumes:
- lldap_data:/data
environment:
LLDAP_JWT_SECRET: I2sNvGvhzZlTJWPfNL9MBPFGhyG/gWU5wHz6wFsIC3I=
LLDAP_LDAP_USER_PASS: /ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
LLDAP_LDAP_BASE_DN: dc=a250,dc=ca
networks:
- default
deploy:
restart_policy:
condition: on-failure
replicas: 1
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
mariadb:
image: git.nixc.us/a250/authelia:production-mariadb
environment:
MYSQL_ROOT_PASSWORD: authelia
MYSQL_DATABASE: authelia
MYSQL_USER: authelia
MYSQL_PASSWORD: authelia
volumes:
- authelia_mariadb_data:/var/lib/mysql:rw
networks:
- default
deploy:
update_config:
order: start-first
failure_action: rollback
parallelism: 1
restart_policy:
condition: on-failure
replicas: 1
labels:
us.a250.autodeploy: "true"
traefik.enable: "false"
# healthcheck:
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "authelia", "-pauthelia"]
# start_period: 15s
# interval: 30s
# timeout: 10s
# retries: 3
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"

220
stack.yml
View File

@ -1,164 +1,30 @@
services: services:
mariadb: atlas-postgres:
image: mariadb:latest image: postgres:16-alpine
environment: environment:
MYSQL_ROOT_PASSWORD: dev_authelia_root POSTGRES_DB: atlas
MYSQL_DATABASE: authelia POSTGRES_USER: atlas
MYSQL_USER: authelia POSTGRES_PASSWORD: atlas
MYSQL_PASSWORD: authelia
volumes: volumes:
- mariadb_data:/var/lib/mysql - atlas_postgres_data:/var/lib/postgresql/data
networks: networks:
- authelia_dev - atlas_internal
healthcheck: healthcheck:
test: [ "CMD", "/usr/local/bin/healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized" ] test: ["CMD-SHELL", "pg_isready -U atlas -d atlas"]
start_period: 30s start_period: 10s
interval: 30s interval: 30s
timeout: 10s timeout: 5s
retries: 5 retries: 5
redis:
image: redis:latest
command: redis-server --appendonly yes
volumes:
- redis_data:/data
networks:
- authelia_dev
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
start_period: 10s
interval: 30s
timeout: 5s
retries: 3
lldap:
image: nitnelave/lldap:latest
volumes:
- lldap_data:/data
environment:
- LLDAP_JWT_SECRET=I2sNvGvhzZlTJWPfNL9MBPFGhyG/gWU5wHz6wFsIC3I=
- LLDAP_LDAP_USER_PASS=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
- LLDAP_LDAP_BASE_DN=dc=a250,dc=ca
- PUID=33
- PGID=33
networks:
- authelia_dev
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.lldap.rule=Host(`bc.a250.ca`) && PathPrefix(`/admin/lldap`)"
- "traefik.http.routers.lldap.middlewares=strip-lldap@swarm"
- "traefik.http.middlewares.strip-lldap.stripprefix.prefixes=/admin/lldap"
- "traefik.http.routers.lldap.entrypoints=websecure"
- "traefik.http.routers.lldap.tls=true"
- "traefik.http.services.lldap.loadbalancer.server.port=17170"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:17170/health" ]
start_period: 10s
interval: 30s
timeout: 5s
retries: 3
authelia:
image: git.nixc.us/a250/authelia:dev-authelia
user: root
command:
- sh
- -c
- |
mkdir -p /run/secrets
echo "$${IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET}" > /run/secrets/IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
echo "$${STORAGE_ENCRYPTION_KEY}" > /run/secrets/STORAGE_ENCRYPTION_KEY
echo "$${SESSION_SECRET}" > /run/secrets/SESSION_SECRET
echo "$${NOTIFIER_SMTP_PASSWORD}" > /run/secrets/NOTIFIER_SMTP_PASSWORD
echo "$${AUTHENTICATION_BACKEND_LDAP_PASSWORD}" > /run/secrets/AUTHENTICATION_BACKEND_LDAP_PASSWORD
echo "$${IDENTITY_PROVIDERS_OIDC_HMAC_SECRET}" > /run/secrets/IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
echo "$${IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY}" > /run/secrets/IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
echo "$${IDENTITY_PROVIDERS_OIDC_JWKS_KEY}" > /run/secrets/IDENTITY_PROVIDERS_OIDC_JWKS_KEY
echo "$${CLIENT_SECRET_HEADSCALE}" > /run/secrets/CLIENT_SECRET_HEADSCALE
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
{ echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/login(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe/?$$'"; echo " - '^/success(/|\\?.*)?$$'"; echo " - '^/webhook/stripe/?$$'"; echo " - '^/resend-reset/?$$'"; echo " - '^/health/?$$'"; echo " - '^/version/?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/activate/?$$'"; echo " - '^/dashboard/?$$'"; echo ' - domain: bc.a250.ca'; echo ' subject:'; echo ' - group:customers'; echo ' policy: two_factor'; echo ' resources:'; echo " - '^/portal/?$$'"; echo " - '^/resubscribe/?$$'"; echo " - '^/stack-manage/?$$'"; echo " - '^/i/[a-z0-9-]+(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/admin/lldap(/.*)?$$'"; echo " - '^/whoami(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: deny'; } > /config/configuration.acl.yml
exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml --config=/config/configuration.notifier.yml --config=/config/configuration.identity.providers.yml --config=/config/configuration.oidc.clients.yml
environment:
AUTHELIA_SERVER_ADDRESS: tcp://0.0.0.0:9091/login
X_AUTHELIA_EMAIL: authelia@a250.ca
X_AUTHELIA_SITE_NAME: a250.ca
X_AUTHELIA_CONFIG_FILTERS: template
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
TRAEFIK_DOMAIN: bc.a250.ca
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
STORAGE_ENCRYPTION_KEY: DvbtMjsNDIC3eqtNaPtdHm/f07dtlHREgieDStTu9NA=
SESSION_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
NOTIFIER_SMTP_PASSWORD: 8P7ah6U5ZjbQ2Faaw1fJoehxJrMOslCu
AUTHENTICATION_BACKEND_LDAP_PASSWORD: /ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
IDENTITY_PROVIDERS_OIDC_HMAC_SECRET: Pq5+dkrmh04daeSEPEXGq6JniiPsgJ6nHBi/ettUGLSKcuZtnaw3em8/BCXn2iFhUqTRdLSeCiWMbo+oEl/ZYA==
IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY: |
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC0JC4jaDhdqk3U
0yDwAh5JVQR84htkPY0Trf5VQYNnBhglo2CqRm6jwjzfOJLBruCUokbG5wJL+OU8
zDm3aQAhF0xWPEr1ad1U+fIezdF4pZ0fDHVAG9MYTwZYD8iYQclVhoKA8M6/gT15
QHq0Fzfgf4U5dmsNH2CWiFi+TAWQ85bxLiXchTnRkoyZ445xBqCuthJyvvUtrZrl
dCAcnNJ6kdGypXwqAuOGrRDz1g9cv52aoJC0k747EnMcmm1HEuR2zGXyw2RM+Sbu
GrUhLk2vCE448zKXuJGEckalMn2yBfaf5RsZYC9j7SwB0ehyNk5Bn4tKuPt38C7T
wWkIoI/DAgMBAAECggEAAIQB/2cmK8GrC14dwAVUu0NoPRTgnMulHCNPxERPV5Va
4fCy/CNlE0iHdODsLdKN7gVkGOAPnGwP+LnIIh0Sbp9q2bkk3C/IMTZ6wCY5E64i
e85E7HQOVjytRfjb/on7RSianKF6PG4Z4PKTgPFE30c+K5XwZIJse/UHKM3kgWLp
exKVvYyKDrERunDJqZbYsxSnixk8TavOWFHkpk0wHYvxso6a7jQfEjDWh3N7lduj
RlaesSO+NJrZDq44zbyJNsFjh4DsNITdBwYXERPUS33Dp+IlrD2SeQMtMBtz+7Ha
Pd8jMpx8Fw/S3CnjSYRRzDj5Z21EfspfoO6v1ULA0QKBgQDyQejBS7QNwNRIcnhO
b6TVOPmqcOL9gR/mkC4VmWFvf4pTA69OOuU/gHeF6+J40Z4tuFggHMoPmZuPi9AL
GSp2UZQHYa7BxTk7XxESflF/8HzgbtFtK/0dUp1l2JN26qha+djQADFFPNWs8abX
wpbKfjPqLzwR8K5kCtbd3WWDrwKBgQC+XDajJ6I4k9hwfYDxb35UkNFjboK4NfTY
u5Eiz1NhbqqkNV8idZhadJfnbgIAymqr9Yf9M9ncAbuUhCDI2r/VL1CLMx/y/DGH
RxlXWq4sArG1xpR1Muc9W8tTT9cf9XDMmuL81wYccXGqv3RpYQM/VtYIRSWvC0HE
FxZCGPa2LQKBgHlg1IGksH4Dk1kJIYYLIgdDGLRxAwoI3DblHnHr+4ml2WRmgDst
/xamAzyyRzJJtHsr1duhEQxn5i0x2/bzkPbfQM/B/ZFQg7BfnWoqqCL2F1tLqtqM
I7HBZuNUc+4s/FU4wYzVy9no9RZFrVaFRJAIU3KOYAaNFJNDawyWlPo5AoGARe6C
c/W/dqF5xfmVQR0Af/ijs6+Jfjr0NBrT+sHHk+ef8Ktaw8IHslNa6r5TJg82mO2e
g7pksppAWxMfKCqUhrDXGgwyFIXpfBT2jkzV530l4+2L5HJK2RO74mNWWHtGcSQF
d3VW3WQfqeaj0YK+Oqqf/nHIokG0a2E/4BBjshECgYAnlU2Fl7uI1lQBbWsckaQ9
EVeSDtrRvNuER0Eh3WFni2affOqB9qAZXNfCZ/goFJoNgk4fww0OqmewX9Y18/3a
vsrm7L7OKFFlM6vmIG1nPX/s5l++mkMe+qRd4B7C4NSF0bzJlweTozQFDp+prp1y
SHERk3EUdAZn7yyIISd/Qg==
-----END PRIVATE KEY-----
IDENTITY_PROVIDERS_OIDC_JWKS_KEY: mbfKKlpQ5QEzrmBCCcOg7yubDBKZtKCAiL7rGtVdMq/hpCorO+Qiei2fKbB/xieDS3BIg5BMza5fZm5w0hMiNA==
CLIENT_SECRET_HEADSCALE: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
CLIENT_SECRET_HEADADMIN: RAxwkJxwMBSYkaA0r+D5qZdEFIrVEZJbigOPtkCBED8=
CLIENT_SECRET_PORTAINER: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
volumes:
- authelia_data:/data
networks:
- authelia_dev
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.authelia.rule=Host(`bc.a250.ca`) && PathPrefix(`/login`)"
- "traefik.http.routers.authelia.priority=10"
- "traefik.http.routers.authelia.entrypoints=websecure"
- "traefik.http.routers.authelia.tls=true"
- "traefik.http.services.authelia.loadbalancer.server.port=9091"
- "traefik.http.middlewares.authelia-auth.forwardauth.address=http://authelia:9091/login/api/authz/forward-auth?rd=https://bc.a250.ca/login/"
- "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
healthcheck:
test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/login/api/health || exit 1" ]
start_period: 15s
interval: 30s
timeout: 10s
retries: 3
traefik: traefik:
image: traefik:v3.1 image: traefik:v3.6
command: command:
- "--api.insecure=true" - "--api.insecure=true"
- "--providers.swarm=true" - "--providers.swarm=true"
- "--providers.swarm.endpoint=unix:///var/run/docker.sock" - "--providers.swarm.endpoint=unix:///var/run/docker.sock"
- "--providers.swarm.watch=true" - "--providers.swarm.watch=true"
- "--providers.swarm.exposedbydefault=false" - "--providers.swarm.exposedbydefault=false"
- "--providers.swarm.network=atlas_authelia_dev" - "--providers.swarm.network=atlas_atlas_internal"
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure" - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https" - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
@ -170,7 +36,7 @@ services:
volumes: volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro" - "/var/run/docker.sock:/var/run/docker.sock:ro"
networks: networks:
- authelia_dev - atlas_internal
deploy: deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
@ -182,41 +48,40 @@ services:
- "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik" - "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik"
- "traefik.http.services.traefik-api.loadbalancer.server.port=8080" - "traefik.http.services.traefik-api.loadbalancer.server.port=8080"
# SUBSCRIBE/STRIPE: Do not remove or reorder. Values are in this file; do not use .env.
# See .cursor/rules/protect-subscribe-settings.mdc
ss-atlas: ss-atlas:
image: atlas-ss-atlas:latest image: atlas-ss-atlas:latest
environment: environment:
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder} - STRIPE_SECRET_KEY=sk_test_51T6uRBRfasa3uSsu1EwvRHaGKhWopjeBz15aDACaI3ectJ1przHIKTX2DAqJu7DDtsBMhIuRiyVf0MY9ivtUvzk800kEZ5advL
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-whsec_placeholder} - STRIPE_WEBHOOK_SECRET=whsec_placeholder
- STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-} - STRIPE_PRICE_ID=price_1T6v8dRfasa3uSsuCWmIC0Fn
- STRIPE_PRICE_ID_FREE=${STRIPE_PRICE_ID_FREE:-} - STRIPE_PRICE_ID_FREE=price_1T7NOURfasa3uSsuEpbKAD1h
- STRIPE_PRICE_ID_YEAR=${STRIPE_PRICE_ID_YEAR:-} - STRIPE_PRICE_ID_YEAR=price_1T7NOURfasa3uSsu3fB9ivyn
- STRIPE_PRICE_ID_MONTH_100=${STRIPE_PRICE_ID_MONTH_100:-} - STRIPE_PRICE_ID_MONTH_100=price_1T7NOVRfasa3uSsuEaxzMNno
- STRIPE_PRICE_ID_MONTH_200=${STRIPE_PRICE_ID_MONTH_200:-} - STRIPE_PRICE_ID_MONTH_200=price_1T7NOVRfasa3uSsucQRRlPCi
- STRIPE_PAYMENT_LINK=${STRIPE_PAYMENT_LINK:-} - STRIPE_PAYMENT_LINK=
- FREE_TIER_LIMIT=${FREE_TIER_LIMIT:-10} - FREE_TIER_LIMIT=10
- YEAR_TIER_LIMIT=${YEAR_TIER_LIMIT:-50} - YEAR_TIER_LIMIT=50
- MAX_SIGNUPS=${MAX_SIGNUPS:-0} - MAX_SIGNUPS=0
- LLDAP_URL=ldap://lldap:3890
- LLDAP_ADMIN_DN=uid=admin,ou=people,dc=a250,dc=ca
- LLDAP_ADMIN_PASSWORD=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
- LLDAP_BASE_DN=dc=a250,dc=ca
- LLDAP_HTTP_URL=http://lldap:17170
- DOCKER_HOST=unix:///var/run/docker.sock - DOCKER_HOST=unix:///var/run/docker.sock
- APP_URL=https://bc.a250.ca - APP_URL=https://bc.a250.ca
- AUTHELIA_URL=https://bc.a250.ca/login - IDENTITY_URL=https://bc.a250.ca/login
- AUTHELIA_INTERNAL_URL=http://authelia:9091/login - DATABASE_URL=postgres://atlas:atlas@atlas-postgres:5432/atlas?sslmode=disable
- TRAEFIK_DOMAIN=bc.a250.ca - TRAEFIK_DOMAIN=bc.a250.ca
- TRAEFIK_NETWORK=authelia_dev - TRAEFIK_NETWORK=atlas_internal
- TRAEFIK_DOCKER_NETWORK=atlas_atlas_internal
- CUSTOMER_DOMAIN=bc.a250.ca - CUSTOMER_DOMAIN=bc.a250.ca
- TEMPLATE_PATH=/app/templates - TEMPLATE_PATH=/app/templates
- ARCHIVE_PATH=/archives - ARCHIVE_PATH=/archives
- LANDING_TAGLINE=${LANDING_TAGLINE:-Your own workspace, ready in minutes.} - LANDING_TAGLINE=Your own workspace, ready in minutes.
- LANDING_FEATURES=${LANDING_FEATURES:-Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime} - LANDING_FEATURES=Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime
- ADMIN_SECRET=
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- atlas_archives:/archives - atlas_archives:/archives
networks: networks:
- authelia_dev - atlas_internal
deploy: deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
@ -224,31 +89,32 @@ services:
- "traefik.http.routers.ss-atlas.entrypoints=websecure" - "traefik.http.routers.ss-atlas.entrypoints=websecure"
- "traefik.http.routers.ss-atlas.tls=true" - "traefik.http.routers.ss-atlas.tls=true"
- "traefik.http.routers.ss-atlas.priority=1" - "traefik.http.routers.ss-atlas.priority=1"
- "traefik.http.routers.ss-atlas.middlewares=authelia-auth@swarm"
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080" - "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
- "traefik.http.routers.ss-atlas-instance.rule=Host(`bc.a250.ca`) && PathPrefix(`/i/`)"
- "traefik.http.routers.ss-atlas-instance.entrypoints=websecure"
- "traefik.http.routers.ss-atlas-instance.tls=true"
- "traefik.http.routers.ss-atlas-instance.priority=15"
- "traefik.http.routers.ss-atlas-instance.service=ss-atlas"
whoami: whoami:
image: traefik/whoami image: traefik/whoami
networks: networks:
- authelia_dev - atlas_internal
deploy: deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`bc.a250.ca`) && PathPrefix(`/whoami`)" - "traefik.http.routers.whoami.rule=Host(`bc.a250.ca`) && PathPrefix(`/whoami`)"
- "traefik.http.routers.whoami.entrypoints=websecure" - "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.tls=true" - "traefik.http.routers.whoami.tls=true"
- "traefik.http.routers.whoami.middlewares=strip-whoami@swarm,authelia-auth@swarm" - "traefik.http.routers.whoami.middlewares=strip-whoami@swarm"
- "traefik.http.middlewares.strip-whoami.stripprefix.prefixes=/whoami" - "traefik.http.middlewares.strip-whoami.stripprefix.prefixes=/whoami"
- "traefik.http.services.whoami.loadbalancer.server.port=80" - "traefik.http.services.whoami.loadbalancer.server.port=80"
networks: networks:
authelia_dev: atlas_internal:
driver: overlay driver: overlay
attachable: true attachable: true
volumes: volumes:
mariadb_data:
redis_data:
authelia_data:
lldap_data:
atlas_archives: atlas_archives:
atlas_postgres_data: