forked from Nixius/authelia
Compare commits
6 Commits
2e8979d4d8
...
8b3ba3ab5a
| Author | SHA1 | Date |
|---|---|---|
|
|
8b3ba3ab5a | |
|
|
ec79638f89 | |
|
|
897e1f6b17 | |
|
|
76e351c7e7 | |
|
|
71b91a4284 | |
|
|
630bd3d3f4 |
|
|
@ -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.
|
||||
|
|
@ -1,5 +1,2 @@
|
|||
# Authelia – stable/done; keep out of context for ss-atlas and other work
|
||||
|
||||
authelia-dev-config.yml
|
||||
docker/mariadb/
|
||||
docker/redis/
|
||||
|
|
@ -32,25 +32,6 @@ steps:
|
|||
from_secret: DOCKER_REGISTRY_USER
|
||||
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:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
|
|
@ -73,25 +54,6 @@ steps:
|
|||
from_secret: REGISTRY_USER
|
||||
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:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
|
|
|
|||
269
README.md
269
README.md
|
|
@ -1,268 +1,13 @@
|
|||
<!-- build 5 -->
|
||||
# Authelia with Traefik (ATLAS)
|
||||
## Authentication Traffic LDAP Application Security
|
||||
# ATLAS
|
||||
|
||||
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
|
||||
- **🚀 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
|
||||
Use the root ship script to build the latest local `ss-atlas` image, push it, and deploy the production stack:
|
||||
|
||||
## 🚀 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
|
||||
```sh
|
||||
./ship.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 (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!
|
||||
The active production stack is defined in `stack.production.yml`. Legacy identity artifacts are preserved under `archives/`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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!
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"Home": "Go to App"
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ theme: grey
|
|||
|
||||
server:
|
||||
address: tcp://:9091
|
||||
asset_path: /config/assets
|
||||
buffers:
|
||||
read: 8192
|
||||
write: 8192
|
||||
|
|
@ -4,9 +4,9 @@
|
|||
<body>
|
||||
<h1>{{ .Title }}</h1>
|
||||
<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>
|
||||
{{ $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 — no changes will be made.</p>
|
||||
<p style="color:#888;font-size:0.85em;">Requested from {{ .RemoteIP }}.</p>
|
||||
</body>
|
||||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
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.
|
||||
|
||||
{{ $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.
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -271,6 +271,31 @@ func (c *Client) CountCustomers() (int, error) {
|
|||
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) {
|
||||
searchReq := goldap.NewSearchRequest(
|
||||
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN),
|
||||
|
|
@ -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_HEADADMIN` | Headscale admin OIDC client | `./generate-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
|
||||
|
||||
|
|
@ -64,7 +63,6 @@ export WOODPECKER_TOKEN=your-api-token
|
|||
|
||||
# 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_GITEA --value "$(cat secrets/clients/gitea-secret.txt)"
|
||||
```
|
||||
|
||||
## 🔄 Secret Rotation
|
||||
|
|
@ -85,7 +83,7 @@ woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --val
|
|||
# Regenerate OAuth client secrets only
|
||||
./scripts/generate-oauth-secrets.sh
|
||||
|
||||
# Update CLIENT_SECRET_PORTAINER and CLIENT_SECRET_GITEA in vault
|
||||
# Update CLIENT_SECRET_PORTAINER in vault
|
||||
# Deploy when convenient
|
||||
```
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -27,10 +27,6 @@ Add these to your Woodpecker CI vault:
|
|||
- **Variable**: `CLIENT_SECRET_PORTAINER`
|
||||
- **Value**: Generated from `secrets/clients/portainer-secret.txt`
|
||||
|
||||
#### Gitea OAuth
|
||||
- **Variable**: `CLIENT_SECRET_GITEA`
|
||||
- **Value**: Generated from `secrets/clients/gitea-secret.txt`
|
||||
|
||||
## 📱 Client Configurations
|
||||
|
||||
### Portainer OAuth Setup
|
||||
|
|
@ -75,39 +71,6 @@ Once OAuth is working, remove middleware protection:
|
|||
# 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
|
||||
|
||||
### 1. Generate Secrets
|
||||
|
|
@ -118,7 +81,6 @@ Configure in Gitea → Site Administration → Authentication Sources:
|
|||
### 2. Update CI/CD Vault
|
||||
Add the generated secrets to your Woodpecker CI vault:
|
||||
- `CLIENT_SECRET_PORTAINER`
|
||||
- `CLIENT_SECRET_GITEA`
|
||||
|
||||
### 3. Deploy Authelia
|
||||
Push changes to trigger CI/CD deployment with new OAuth clients.
|
||||
|
|
@ -5,7 +5,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
|
|||
## 📚 Available Guides
|
||||
|
||||
### 🔧 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
|
||||
|
||||
### 🚀 Getting Started
|
||||
|
|
@ -18,7 +18,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
|
|||
2. **OAuth Integration**
|
||||
- Generate OAuth client secrets with `./scripts/generate-oauth-secrets.sh`
|
||||
- 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**
|
||||
- Commit changes to trigger CI/CD pipeline
|
||||
|
|
@ -55,7 +55,7 @@ docker compose -f docker-compose.dev.yml up -d
|
|||
### Required Secrets (12 Total)
|
||||
- **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP
|
||||
- **OIDC Secrets (3)**: HMAC, private key, JWKS key
|
||||
- **Client Secrets (4)**: Headscale (2), Portainer, Gitea
|
||||
- **Client Secrets (3)**: Headscale (2), Portainer
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
|
|
@ -4,7 +4,7 @@ set -e
|
|||
|
||||
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
|
||||
echo "ERROR: Working tree is dirty. Commit your changes before deploying." >&2
|
||||
|
|
@ -106,11 +106,6 @@ Add these secrets to your Woodpecker CI vault:
|
|||
- **Secret File**: `secrets/clients/portainer-secret.txt`
|
||||
- **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
|
||||
|
||||
1. **Never commit these files** - they are automatically gitignored
|
||||
|
|
@ -124,9 +119,6 @@ If using Woodpecker CLI:
|
|||
```bash
|
||||
# Update Portainer secret
|
||||
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
|
||||
|
|
@ -149,17 +141,15 @@ print_summary() {
|
|||
echo "${YELLOW}📁 Generated Files:${NC}"
|
||||
echo " • secrets/oauth-secrets.env"
|
||||
echo " • secrets/clients/portainer-secret.txt"
|
||||
echo " • secrets/clients/gitea-secret.txt"
|
||||
echo " • secrets/VAULT_SECRETS.md"
|
||||
echo
|
||||
echo "${YELLOW}🔑 Required CI/CD Vault Updates:${NC}"
|
||||
echo " • CLIENT_SECRET_PORTAINER"
|
||||
echo " • CLIENT_SECRET_GITEA"
|
||||
echo
|
||||
echo "${RED}⚠️ NEXT STEPS:${NC}"
|
||||
echo " 1. Update your CI/CD vault with new secrets"
|
||||
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
|
||||
echo "${BLUE}📖 Full setup guide: docs/OAUTH_SETUP.md${NC}"
|
||||
|
|
@ -195,8 +185,7 @@ main() {
|
|||
|
||||
# Generate client secrets
|
||||
generate_client_secret "portainer" "portainer-secret.txt"
|
||||
generate_client_secret "gitea" "gitea-secret.txt"
|
||||
|
||||
|
||||
create_vault_instructions
|
||||
print_summary
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,18 +1,9 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
ss-atlas:
|
||||
build:
|
||||
context: ./docker/mariadb/
|
||||
dockerfile: Dockerfile.production
|
||||
image: git.nixc.us/a250/authelia:production-mariadb
|
||||
redis:
|
||||
build:
|
||||
context: ./docker/redis/
|
||||
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
|
||||
context: ./docker/ss-atlas/
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BUILD_COMMIT: ${BUILD_COMMIT:-unknown}
|
||||
BUILD_TIME: ${BUILD_TIME:-unknown}
|
||||
image: git.nixc.us/a250/ss-atlas:production
|
||||
|
|
@ -1,10 +1,4 @@
|
|||
services:
|
||||
authelia:
|
||||
build:
|
||||
context: ./docker/authelia/
|
||||
dockerfile: Dockerfile
|
||||
image: git.nixc.us/a250/authelia:dev-authelia
|
||||
|
||||
ss-atlas:
|
||||
build:
|
||||
context: ./docker/ss-atlas/
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.nixc.us/a250/ss-atlas/internal/accounts"
|
||||
"git.nixc.us/a250/ss-atlas/internal/config"
|
||||
"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"
|
||||
"git.nixc.us/a250/ss-atlas/internal/swarm"
|
||||
"git.nixc.us/a250/ss-atlas/internal/version"
|
||||
|
|
@ -22,10 +22,14 @@ func main() {
|
|||
cfg := config.Load()
|
||||
|
||||
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)
|
||||
|
||||
router := handlers.NewRouter(cfg, stripeClient, ldapClient, swarmClient)
|
||||
router := handlers.NewRouter(cfg, stripeClient, accountStore, swarmClient)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,8 @@ go 1.23
|
|||
|
||||
require (
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
)
|
||||
require github.com/stretchr/testify v1.8.1 // indirect
|
||||
|
|
|
|||
|
|
@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
|
||||
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/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
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/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.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.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/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/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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,68 +7,65 @@ import (
|
|||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
AppURL string
|
||||
AutheliaURL string
|
||||
AutheliaInternalURL string
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceID string // Fallback when tier prices not set
|
||||
StripePaymentLink string // Optional: legacy Payment Link for $0
|
||||
StripePriceIDFree string // $0/3mo, auto-cancel (first 10)
|
||||
StripePriceIDYear string // $20/year (customers 11–50)
|
||||
Port string
|
||||
AppURL string
|
||||
IdentityURL string
|
||||
DatabaseURL string
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceID string // Fallback when tier prices not set
|
||||
StripePaymentLink string // Optional: legacy Payment Link for $0
|
||||
StripePriceIDFree string // $0/3mo, auto-cancel (first 10)
|
||||
StripePriceIDYear string // $20/year (customers 11–50)
|
||||
StripePriceIDMonth100 string // $100/month (after year for 11–50)
|
||||
StripePriceIDMonth200 string // $200/month (customers 51+)
|
||||
FreeTierLimit int // First N get free tier (default 10)
|
||||
YearTierLimit int // Up to this count get year tier (default 50)
|
||||
MaxSignups int // Cap on new signups (0 = no limit)
|
||||
LDAPUrl string
|
||||
LDAPAdminDN string
|
||||
LDAPAdminPassword string
|
||||
LDAPBaseDN string
|
||||
LLDAPHttpURL string
|
||||
DockerHost string
|
||||
TraefikDomain string
|
||||
TraefikNetwork string
|
||||
TemplatePath string
|
||||
CustomerDomain string
|
||||
ArchivePath string
|
||||
LandingTagline string // Main tagline under logo
|
||||
LandingFeatures []string // Bullet points on subscribe card (comma-separated in env)
|
||||
FreeTierLimit int // First N get free tier (default 10)
|
||||
YearTierLimit int // Up to this count get year tier (default 50)
|
||||
MaxSignups int // Cap on new signups (0 = no limit)
|
||||
DockerHost string
|
||||
TraefikDomain string
|
||||
TraefikNetwork string
|
||||
TraefikDockerNetwork string
|
||||
TemplatePath string
|
||||
CustomerDomain string
|
||||
ArchivePath string
|
||||
LandingTagline string // Main tagline under logo
|
||||
LandingFeatures []string // Bullet points on subscribe card (comma-separated in env)
|
||||
AdminSecret string // If set, enables POST /admin/delete-user (X-Admin-Secret header)
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
traefikNetwork := envOrDefault("TRAEFIK_NETWORK", "atlas_internal")
|
||||
traefikDockerNetwork := envOrDefault("TRAEFIK_DOCKER_NETWORK", "atlas_"+traefikNetwork)
|
||||
|
||||
return &Config{
|
||||
Port: envOrDefault("PORT", "8080"),
|
||||
AppURL: envOrDefault("APP_URL", "https://bc.a250.ca"),
|
||||
AutheliaURL: envOrDefault("AUTHELIA_URL", "https://bc.a250.ca/login"),
|
||||
AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091/login"),
|
||||
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
|
||||
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
|
||||
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
|
||||
StripePaymentLink: envOrDefault("STRIPE_PAYMENT_LINK", ""),
|
||||
StripePriceIDFree: envOrDefault("STRIPE_PRICE_ID_FREE", ""),
|
||||
StripePriceIDYear: envOrDefault("STRIPE_PRICE_ID_YEAR", ""),
|
||||
Port: envOrDefault("PORT", "8080"),
|
||||
AppURL: envOrDefault("APP_URL", "https://bc.a250.ca"),
|
||||
IdentityURL: envOrDefault("IDENTITY_URL", "https://bc.a250.ca/login"),
|
||||
DatabaseURL: envOrDefault("DATABASE_URL", "postgres://atlas:atlas@postgres:5432/atlas?sslmode=disable"),
|
||||
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
|
||||
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
|
||||
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
|
||||
StripePaymentLink: envOrDefault("STRIPE_PAYMENT_LINK", ""),
|
||||
StripePriceIDFree: envOrDefault("STRIPE_PRICE_ID_FREE", ""),
|
||||
StripePriceIDYear: envOrDefault("STRIPE_PRICE_ID_YEAR", ""),
|
||||
StripePriceIDMonth100: envOrDefault("STRIPE_PRICE_ID_MONTH_100", ""),
|
||||
StripePriceIDMonth200: envOrDefault("STRIPE_PRICE_ID_MONTH_200", ""),
|
||||
FreeTierLimit: envIntOrDefault("FREE_TIER_LIMIT", 10),
|
||||
YearTierLimit: envIntOrDefault("YEAR_TIER_LIMIT", 50),
|
||||
MaxSignups: envIntOrDefault("MAX_SIGNUPS", 0),
|
||||
LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"),
|
||||
LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"),
|
||||
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""),
|
||||
LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"),
|
||||
LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"),
|
||||
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
||||
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
|
||||
TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"),
|
||||
TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"),
|
||||
CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "bc.a250.ca"),
|
||||
ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"),
|
||||
FreeTierLimit: envIntOrDefault("FREE_TIER_LIMIT", 10),
|
||||
YearTierLimit: envIntOrDefault("YEAR_TIER_LIMIT", 50),
|
||||
MaxSignups: envIntOrDefault("MAX_SIGNUPS", 0),
|
||||
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
||||
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
|
||||
TraefikNetwork: traefikNetwork,
|
||||
TraefikDockerNetwork: traefikDockerNetwork,
|
||||
TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"),
|
||||
CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "bc.a250.ca"),
|
||||
ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"),
|
||||
LandingTagline: envOrDefault("LANDING_TAGLINE",
|
||||
"Your own workspace, ready in minutes."),
|
||||
LandingFeatures: envListOrDefault("LANDING_FEATURES",
|
||||
[]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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,9 @@ func TestEnvOrDefault(t *testing.T) {
|
|||
func TestLoadDefaults(t *testing.T) {
|
||||
// Clear env vars that Load uses
|
||||
envKeys := []string{
|
||||
"PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "STRIPE_PAYMENT_LINK", "MAX_SIGNUPS", "LLDAP_URL",
|
||||
"LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN",
|
||||
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
|
||||
"PORT", "APP_URL", "IDENTITY_URL", "DATABASE_URL", "STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "STRIPE_PAYMENT_LINK", "MAX_SIGNUPS",
|
||||
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TRAEFIK_DOCKER_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
|
||||
}
|
||||
for _, k := range envKeys {
|
||||
os.Unsetenv(k)
|
||||
|
|
@ -49,13 +48,12 @@ func TestLoadDefaults(t *testing.T) {
|
|||
}{
|
||||
{"Port", cfg.Port, "8080"},
|
||||
{"AppURL", cfg.AppURL, "https://bc.a250.ca"},
|
||||
{"AutheliaURL", cfg.AutheliaURL, "https://bc.a250.ca/login"},
|
||||
{"LDAPUrl", cfg.LDAPUrl, "ldap://lldap_lldap:3890"},
|
||||
{"LDAPAdminDN", cfg.LDAPAdminDN, "uid=admin,ou=people,dc=a250,dc=ca"},
|
||||
{"LDAPBaseDN", cfg.LDAPBaseDN, "dc=a250,dc=ca"},
|
||||
{"IdentityURL", cfg.IdentityURL, "https://bc.a250.ca/login"},
|
||||
{"DatabaseURL", cfg.DatabaseURL, "postgres://atlas:atlas@postgres:5432/atlas?sslmode=disable"},
|
||||
{"DockerHost", cfg.DockerHost, "unix:///var/run/docker.sock"},
|
||||
{ "TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
|
||||
{"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"},
|
||||
{"TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
|
||||
{"TraefikNetwork", cfg.TraefikNetwork, "atlas_internal"},
|
||||
{"TraefikDockerNetwork", cfg.TraefikDockerNetwork, "atlas_atlas_internal"},
|
||||
{"TemplatePath", cfg.TemplatePath, "/app/templates"},
|
||||
{"CustomerDomain", cfg.CustomerDomain, "bc.a250.ca"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
|
||||
remoteUser := r.Header.Get("Remote-User")
|
||||
if remoteUser == "" {
|
||||
acct, identity, err := a.currentAccount(r)
|
||||
if err != nil || acct == nil {
|
||||
data := map[string]any{
|
||||
"AutheliaURL": a.cfg.AutheliaURL,
|
||||
"IdentityURL": a.cfg.IdentityURL,
|
||||
"AppURL": a.cfg.AppURL,
|
||||
"NeedLogin": true,
|
||||
}
|
||||
|
|
@ -18,16 +17,15 @@ func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers")
|
||||
if inGroup {
|
||||
if isSubscribedAccount(acct) {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"User": remoteUser,
|
||||
"AppURL": a.cfg.AppURL,
|
||||
"Ready": true,
|
||||
"User": accountDisplay(acct, identity),
|
||||
"AppURL": a.cfg.AppURL,
|
||||
"Ready": true,
|
||||
}
|
||||
if err := a.tmpl.ExecuteTemplate(w, "activate.html", data); err != nil {
|
||||
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) {
|
||||
remoteUser := r.Header.Get("Remote-User")
|
||||
if remoteUser == "" {
|
||||
acct, _, err := a.currentAccount(r)
|
||||
if err != nil || acct == nil {
|
||||
http.Error(w, "not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers")
|
||||
if inGroup {
|
||||
if isSubscribedAccount(acct) {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.ldap.AddToGroup(remoteUser, "customers"); err != nil {
|
||||
log.Printf("activate: group add failed for %s: %v", remoteUser, err)
|
||||
http.Error(w, "activation failed, contact support", http.StatusInternalServerError)
|
||||
if acct.StripeCustomerID == "" {
|
||||
http.Error(w, "no paid checkout found", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
stackName := fmt.Sprintf("customer-%s", remoteUser)
|
||||
if err := a.swarm.RestoreVolumes(stackName, a.cfg.ArchivePath); err != nil {
|
||||
log.Printf("activate: volume restore failed for %s: %v", remoteUser, err)
|
||||
inst, err := a.accounts.InstanceByAccountID(r.Context(), acct.ID)
|
||||
if err != nil {
|
||||
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 {
|
||||
log.Printf("activate: stack deploy failed for %s: %v", remoteUser, err)
|
||||
if err := a.accounts.MarkSubscriptionStatus(r.Context(), acct.StripeCustomerID, "active"); err != nil {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
|
|
@ -10,18 +9,10 @@ import (
|
|||
)
|
||||
|
||||
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
remoteUser := r.Header.Get("Remote-User")
|
||||
remoteEmail := r.Header.Get("Remote-Email")
|
||||
remoteGroups := r.Header.Get("Remote-Groups")
|
||||
isSubscribed := remoteGroups != "" && contains(remoteGroups, "customers")
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
acct, identity, err := a.currentAccount(r)
|
||||
remoteUser := accountDisplay(acct, identity)
|
||||
remoteEmail := firstNonEmpty(identity.Email, "")
|
||||
isSubscribed := isSubscribedAccount(acct)
|
||||
|
||||
var customerID string
|
||||
stackDeployed := false
|
||||
|
|
@ -29,59 +20,62 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||
var subStatus *ssstripe.SubscriptionStatus
|
||||
paidNotActivated := false
|
||||
|
||||
if remoteUser != "" {
|
||||
cid, _ := a.ldap.GetStripeCustomerID(remoteUser)
|
||||
if cid != "" && !isSubscribed {
|
||||
paidNotActivated = true
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("dashboard: account lookup failed: %v", err)
|
||||
}
|
||||
if acct != nil && acct.StripeCustomerID != "" && !isSubscribed {
|
||||
paidNotActivated = true
|
||||
}
|
||||
|
||||
if isSubscribed && remoteUser != "" {
|
||||
cid, err := a.ldap.GetStripeCustomerID(remoteUser)
|
||||
if err != nil {
|
||||
log.Printf("dashboard: failed to get stripe customer id for %s: %v", remoteUser, err)
|
||||
}
|
||||
customerID = cid
|
||||
if cid != "" {
|
||||
subStatus = a.stripe.GetCustomerSubscriptionStatus(cid)
|
||||
var instSlug string
|
||||
var customerDomain string
|
||||
if isSubscribed && acct != nil {
|
||||
customerID = acct.StripeCustomerID
|
||||
if customerID != "" {
|
||||
subStatus = a.stripe.GetCustomerSubscriptionStatus(customerID)
|
||||
}
|
||||
if subStatus == nil {
|
||||
subStatus = &ssstripe.SubscriptionStatus{Label: "Active", Badge: "badge-active"}
|
||||
}
|
||||
|
||||
stackName := fmt.Sprintf("customer-%s", remoteUser)
|
||||
exists, err := a.swarm.StackExists(stackName)
|
||||
if err != nil {
|
||||
log.Printf("dashboard: stack check failed for %s: %v", remoteUser, err)
|
||||
inst, err := a.accounts.InstanceByAccountID(r.Context(), acct.ID)
|
||||
if err == nil {
|
||||
instSlug = inst.Slug
|
||||
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{
|
||||
"AppURL": a.cfg.AppURL,
|
||||
"AutheliaURL": a.cfg.AutheliaURL,
|
||||
"IdentityURL": a.cfg.IdentityURL,
|
||||
"User": remoteUser,
|
||||
"Email": remoteEmail,
|
||||
"Groups": remoteGroups,
|
||||
"Groups": identity.Groups,
|
||||
"Domain": a.cfg.TraefikDomain,
|
||||
"InstanceSlug": instSlug,
|
||||
"IsSubscribed": isSubscribed,
|
||||
"PaidNotActivated": paidNotActivated,
|
||||
"CustomerID": customerID,
|
||||
"SubStatus": subStatus,
|
||||
"StackDeployed": stackDeployed,
|
||||
"StackRunning": stackRunning,
|
||||
"CustomerDomain": customerDomain,
|
||||
"Commit": version.Commit,
|
||||
"BuildTime": version.BuildTime,
|
||||
"CustomerID": customerID,
|
||||
"SubStatus": subStatus,
|
||||
"StackDeployed": stackDeployed,
|
||||
"StackRunning": stackRunning,
|
||||
"CustomerDomain": customerDomain,
|
||||
"StackError": r.URL.Query().Get("stack_error"),
|
||||
"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 {
|
||||
|
|
|
|||
|
|
@ -94,4 +94,3 @@ func TestHealthRoute(t *testing.T) {
|
|||
t.Errorf("GET /health body = %q, want ok", body)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -6,8 +6,8 @@ import (
|
|||
"net/http"
|
||||
"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/ldap"
|
||||
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
|
||||
"git.nixc.us/a250/ss-atlas/internal/swarm"
|
||||
"git.nixc.us/a250/ss-atlas/internal/version"
|
||||
|
|
@ -16,24 +16,24 @@ import (
|
|||
)
|
||||
|
||||
type App struct {
|
||||
cfg *config.Config
|
||||
stripe *ssstripe.Client
|
||||
ldap *ldap.Client
|
||||
swarm *swarm.Client
|
||||
tmpl *template.Template
|
||||
cfg *config.Config
|
||||
stripe *ssstripe.Client
|
||||
accounts *accounts.Store
|
||||
swarm *swarm.Client
|
||||
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")
|
||||
partialsPattern := filepath.Join(cfg.TemplatePath, "partials", "*.html")
|
||||
tmpl := template.Must(template.Must(template.ParseGlob(partialsPattern)).ParseGlob(tmplPattern))
|
||||
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
stripe: sc,
|
||||
ldap: lc,
|
||||
swarm: sw,
|
||||
tmpl: tmpl,
|
||||
cfg: cfg,
|
||||
stripe: sc,
|
||||
accounts: accountStore,
|
||||
swarm: sw,
|
||||
tmpl: tmpl,
|
||||
}
|
||||
|
||||
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.Get("/", app.handleLanding)
|
||||
r.Get("/checkout", app.handleCheckout)
|
||||
r.Get("/success", app.handleSuccess)
|
||||
r.Get("/activate", app.handleActivateGet)
|
||||
r.Post("/activate", app.handleActivatePost)
|
||||
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("/subscribe", app.handleCreateCheckout)
|
||||
r.Post("/resend-reset", app.handleResendReset)
|
||||
r.Post("/link-stripe-customer", app.handleLinkStripeCustomer)
|
||||
r.Post("/portal", app.handlePortal)
|
||||
r.Post("/resubscribe", app.handleResubscribe)
|
||||
r.Post("/webhook/stripe", app.handleWebhook)
|
||||
r.Post("/admin/delete-user", app.handleDeleteUser)
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
|
|
|
|||
|
|
@ -1,70 +1,79 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
|
||||
remoteUser := r.Header.Get("Remote-User")
|
||||
remoteGroups := r.Header.Get("Remote-Groups")
|
||||
if remoteUser == "" {
|
||||
http.Redirect(w, r, a.cfg.AutheliaURL, http.StatusSeeOther)
|
||||
acct, _, err := a.currentAccount(r)
|
||||
if err != nil || acct == nil {
|
||||
http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !contains(remoteGroups, "customers") {
|
||||
if !isSubscribedAccount(acct) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
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")
|
||||
stackName := fmt.Sprintf("customer-%s", remoteUser)
|
||||
|
||||
switch action {
|
||||
case "stop":
|
||||
if err := a.swarm.ScaleStack(stackName, 0); err != nil {
|
||||
log.Printf("stack-manage stop %s: %v", remoteUser, err)
|
||||
http.Error(w, "failed to stop stack", http.StatusInternalServerError)
|
||||
if err := a.swarm.ScaleStack(inst.StackName, 0); err != nil {
|
||||
log.Printf("stack-manage stop %s: %v", inst.StackName, err)
|
||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
||||
return
|
||||
}
|
||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "stopped", false)
|
||||
|
||||
case "start":
|
||||
exists, _ := a.swarm.StackExists(stackName)
|
||||
exists, _ := a.swarm.StackExists(inst.StackName)
|
||||
if !exists {
|
||||
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
|
||||
log.Printf("stack-manage start (deploy) %s: %v", remoteUser, err)
|
||||
http.Error(w, "failed to start stack", http.StatusInternalServerError)
|
||||
if err := a.swarm.DeployStack(inst.StackName, inst.Slug, a.cfg.TraefikDomain); err != nil {
|
||||
log.Printf("stack-manage start (deploy) %s: %v", inst.StackName, err)
|
||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := a.swarm.ScaleStack(stackName, 1); err != nil {
|
||||
log.Printf("stack-manage start (scale) %s: %v", remoteUser, err)
|
||||
http.Error(w, "failed to start stack", http.StatusInternalServerError)
|
||||
if err := a.swarm.ScaleStack(inst.StackName, 1); err != nil {
|
||||
log.Printf("stack-manage start (scale) %s: %v", inst.StackName, err)
|
||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true)
|
||||
|
||||
case "restart":
|
||||
if err := a.swarm.RestartStack(stackName); err != nil {
|
||||
log.Printf("stack-manage restart %s: %v", remoteUser, err)
|
||||
http.Error(w, "failed to restart stack", http.StatusInternalServerError)
|
||||
if err := a.swarm.RestartStack(inst.StackName); err != nil {
|
||||
log.Printf("stack-manage restart %s: %v", inst.StackName, err)
|
||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
||||
return
|
||||
}
|
||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true)
|
||||
|
||||
case "rebuild":
|
||||
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
|
||||
log.Printf("stack-manage rebuild %s: %v", remoteUser, err)
|
||||
http.Error(w, "failed to rebuild stack", http.StatusInternalServerError)
|
||||
if err := a.swarm.DeployStack(inst.StackName, inst.Slug, a.cfg.TraefikDomain); err != nil {
|
||||
log.Printf("stack-manage rebuild %s: %v", inst.StackName, err)
|
||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
||||
return
|
||||
}
|
||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true)
|
||||
|
||||
case "destroy":
|
||||
if err := a.swarm.RemoveStack(stackName); err != nil {
|
||||
log.Printf("stack-manage destroy %s: %v", remoteUser, err)
|
||||
http.Error(w, "failed to destroy stack", http.StatusInternalServerError)
|
||||
if err := a.swarm.RemoveStack(inst.StackName); err != nil {
|
||||
log.Printf("stack-manage destroy %s: %v", inst.StackName, err)
|
||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
||||
return
|
||||
}
|
||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "destroyed", false)
|
||||
|
||||
default:
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,37 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.nixc.us/a250/ss-atlas/internal/accounts"
|
||||
"git.nixc.us/a250/ss-atlas/internal/pricing"
|
||||
"git.nixc.us/a250/ss-atlas/internal/stripe"
|
||||
"git.nixc.us/a250/ss-atlas/internal/validation"
|
||||
"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) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Logged-in user who paid but hasn't activated yet — send to activate.
|
||||
if remoteUser != "" {
|
||||
custID, _ := a.ldap.GetStripeCustomerID(remoteUser)
|
||||
if custID != "" {
|
||||
http.Redirect(w, r, a.cfg.AppURL+"/activate", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if acct != nil && acct.StripeCustomerID != "" {
|
||||
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
|
||||
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) {
|
||||
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 {
|
||||
count, err := a.ldap.CountCustomers()
|
||||
count, err := a.accounts.CountCustomers(r.Context())
|
||||
if err == nil && count >= a.cfg.MaxSignups {
|
||||
http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rawEmail := r.FormValue("email")
|
||||
email := validation.SanitizeEmail(rawEmail)
|
||||
email := validation.SanitizeEmail(firstNonEmpty(acct.PrimaryEmail, identity.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
|
||||
}
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
|
|
@ -86,8 +126,8 @@ func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
count, _ := a.ldap.CountCustomers()
|
||||
sess, err := a.stripe.CreateCheckoutSession(email, domain, phone, count)
|
||||
count, _ := a.accounts.CountCustomers(r.Context())
|
||||
sess, err := a.stripe.CreateCheckoutSession(email, domain, phone, acct.ID, count)
|
||||
if err != nil {
|
||||
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)
|
||||
|
|
@ -109,7 +149,7 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if a.cfg.MaxSignups > 0 {
|
||||
count, err := a.ldap.CountCustomers()
|
||||
count, err := a.accounts.CountCustomers(r.Context())
|
||||
if err == nil && count >= a.cfg.MaxSignups {
|
||||
http.Error(w, "signup limit reached, contact support", http.StatusServiceUnavailable)
|
||||
return
|
||||
|
|
@ -130,68 +170,101 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
email := sess.CustomerDetails.Email
|
||||
customerID := sess.Customer.ID
|
||||
username := sanitizeUsername(email)
|
||||
phone := ""
|
||||
domain := ""
|
||||
accountID := int64(0)
|
||||
if sess.Metadata != nil {
|
||||
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 {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if sess.Metadata != nil && sess.Metadata["customer_domain"] != "" {
|
||||
if err := a.ldap.SetCustomerDomain(result.Username, sess.Metadata["customer_domain"]); err != nil {
|
||||
log.Printf("ldap set customer domain failed for %s: %v", result.Username, err)
|
||||
exists, _ := a.swarm.StackExists(inst.StackName)
|
||||
if !exists {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Grant active subscription: add to customers group so dashboard shows subscribed.
|
||||
if err := a.ldap.AddToGroup(result.Username, "customers"); err != nil {
|
||||
log.Printf("ldap add to customers failed for %s: %v (create group 'customers' in LLDAP admin if missing)", result.Username, err)
|
||||
if err := a.tmpl.ExecuteTemplate(w, "success.html", map[string]any{
|
||||
"AppURL": a.cfg.AppURL,
|
||||
"IdentityURL": a.cfg.IdentityURL,
|
||||
"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")
|
||||
|
||||
if result.IsNew || !inGroup {
|
||||
// New or lapsed: send password email, show success page.
|
||||
if err := a.triggerPasswordReset(r, result.Username); err != nil {
|
||||
log.Printf("authelia reset trigger failed for %s: %v", username, err)
|
||||
} 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)
|
||||
}
|
||||
// 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).
|
||||
func (a *App) handleLinkStripeCustomer(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
|
||||
}
|
||||
|
||||
// Returning active customer: ensure stack exists, go to dashboard
|
||||
stackName := fmt.Sprintf("customer-%s", result.Username)
|
||||
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)
|
||||
}
|
||||
if !isSubscribedAccount(acct) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Printf("resubscribe: %s payment verified, redirecting to dashboard", result.Username)
|
||||
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
|
||||
if acct.StripeCustomerID != "" {
|
||||
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) {
|
||||
customerID := r.FormValue("customer_id")
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -211,11 +284,11 @@ func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
|
|||
func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := r.FormValue("customer_id")
|
||||
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
|
||||
}
|
||||
|
||||
count, _ := a.ldap.CountCustomers()
|
||||
count, _ := a.accounts.CountCustomers(r.Context())
|
||||
sess, err := a.stripe.CreateCheckoutForCustomer(customerID, count)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
func isSubscribedAccount(acct *accounts.Account) bool {
|
||||
return acct != nil && acct.SubscriptionStatus == "active"
|
||||
}
|
||||
|
||||
func sanitizeUsername(email string) string {
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
local := parts[0]
|
||||
|
|
@ -251,3 +328,18 @@ func sanitizeUsername(email string) string {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.nixc.us/a250/ss-atlas/internal/accounts"
|
||||
stripego "github.com/stripe/stripe-go/v84"
|
||||
"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)
|
||||
}
|
||||
|
||||
// Reconciliation backstop: ensures LLDAP user + Stripe ID are set.
|
||||
// Does NOT send password reset — that's the success page's responsibility.
|
||||
// Reconciliation backstop: ensures the account, billing link, and instance exist.
|
||||
func (a *App) onCheckoutCompleted(event stripego.Event) {
|
||||
var sess stripego.CheckoutSession
|
||||
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
|
||||
customerID := sess.Customer.ID
|
||||
username := sanitizeUsername(email)
|
||||
phone := ""
|
||||
domain := ""
|
||||
accountID := int64(0)
|
||||
if sess.Metadata != nil {
|
||||
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 {
|
||||
count, err := a.ldap.CountCustomers()
|
||||
count, err := a.accounts.CountCustomers(context.Background())
|
||||
if err == nil && count >= a.cfg.MaxSignups {
|
||||
log.Printf("webhook: signup limit reached (%d), skipping provision for %s", a.cfg.MaxSignups, email)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.ldap.EnsureUser(username, email, customerID, phone); err != nil {
|
||||
log.Printf("webhook: ldap ensure user failed: %v", err)
|
||||
}
|
||||
if err := a.ldap.AddToGroup(username, "customers"); err != nil {
|
||||
log.Printf("webhook: ldap add to customers failed for %s: %v", username, err)
|
||||
}
|
||||
if sess.Metadata != nil {
|
||||
if d := sess.Metadata["customer_domain"]; d != "" {
|
||||
if err := a.ldap.SetCustomerDomain(username, d); err != nil {
|
||||
log.Printf("webhook: ldap set customer domain failed for %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
_, _, err := a.accounts.UpsertCheckout(context.Background(), accounts.CheckoutInput{
|
||||
AccountID: accountID,
|
||||
Email: email,
|
||||
DisplayName: email,
|
||||
Phone: phone,
|
||||
CustomerDomain: domain,
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: subscriptionIDFromSession(&sess),
|
||||
StripeSessionID: sess.ID,
|
||||
StripeEventID: event.ID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("webhook: account ensure failed: %v", err)
|
||||
}
|
||||
|
||||
subID := ""
|
||||
|
|
@ -112,26 +119,26 @@ func (a *App) onSubscriptionDeleted(event stripego.Event) {
|
|||
customerID := sub.Customer.ID
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
if err := a.ldap.RemoveFromGroup(username, "customers"); err != nil {
|
||||
log.Printf("ldap group remove failed: %v", err)
|
||||
if err := a.accounts.MarkSubscriptionStatus(context.Background(), customerID, "cancelled"); err != nil && !errors.Is(err, accounts.ErrNotFound) {
|
||||
log.Printf("subscription status update failed: %v", err)
|
||||
}
|
||||
|
||||
stackName := "customer-" + username
|
||||
if err := a.swarm.ArchiveVolumes(stackName, a.cfg.ArchivePath); err != nil {
|
||||
log.Printf("archive failed for %s: %v", stackName, err)
|
||||
if err := a.swarm.ArchiveVolumes(inst.StackName, a.cfg.ArchivePath); err != nil {
|
||||
log.Printf("archive failed for %s: %v", inst.StackName, err)
|
||||
}
|
||||
|
||||
if err := a.swarm.RemoveStack(stackName); err != nil {
|
||||
log.Printf("stack remove failed for %s: %v", stackName, err)
|
||||
if err := a.swarm.RemoveStack(inst.StackName); err != nil {
|
||||
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) {
|
||||
|
|
@ -143,6 +150,9 @@ func (a *App) onSubscriptionUpdated(event stripego.Event) {
|
|||
|
||||
if sub.Status == stripego.SubscriptionStatusCanceled ||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
stripego "github.com/stripe/stripe-go/v84"
|
||||
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
|
||||
checkoutsession "github.com/stripe/stripe-go/v84/checkout/session"
|
||||
"github.com/stripe/stripe-go/v84/customer"
|
||||
"github.com/stripe/stripe-go/v84/subscription"
|
||||
)
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ func (c *Client) priceForTier(t pricing.Tier) string {
|
|||
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)
|
||||
priceID := c.priceForTier(t)
|
||||
if priceID == "" {
|
||||
|
|
@ -76,6 +77,9 @@ func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone stri
|
|||
if 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)))
|
||||
return checkoutsession.New(params)
|
||||
}
|
||||
|
|
@ -102,6 +106,18 @@ func (c *Client) CreateCheckoutForCustomer(customerID string, customerCount int)
|
|||
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) {
|
||||
params := &stripego.BillingPortalSessionParams{
|
||||
Customer: stripego.String(customerID),
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ func (c *Client) DeployStack(stackName, username, domain string) error {
|
|||
}
|
||||
|
||||
data := map[string]any{
|
||||
"ID": username,
|
||||
"Subdomain": username,
|
||||
"Domain": domain,
|
||||
"TraefikNetwork": c.cfg.TraefikNetwork,
|
||||
"ID": username,
|
||||
"Subdomain": username,
|
||||
"Domain": domain,
|
||||
"TraefikDockerNetwork": c.cfg.TraefikDockerNetwork,
|
||||
}
|
||||
|
||||
var rendered bytes.Buffer
|
||||
|
|
@ -90,6 +90,34 @@ func (c *Client) RemoveStack(stackName string) error {
|
|||
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) {
|
||||
cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}")
|
||||
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
|
||||
|
|
|
|||
|
|
@ -7,18 +7,23 @@
|
|||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e4e4e7;
|
||||
--muted: #a1a1aa;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--green: #22c55e;
|
||||
--page: #eff8f8;
|
||||
--surface: #ffffff;
|
||||
--navy: #172f43;
|
||||
--border: #d8e7e5;
|
||||
--text: #14202a;
|
||||
--muted: #5c6f77;
|
||||
--accent: #75d46b;
|
||||
--accent-hover: #5fbd55;
|
||||
--green: #238b4b;
|
||||
--blue: #276f94;
|
||||
}
|
||||
body {
|
||||
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);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
|
|
@ -28,25 +33,37 @@
|
|||
padding: 2rem;
|
||||
}
|
||||
.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-green { color: var(--green); }
|
||||
.subtitle-muted { color: var(--muted); }
|
||||
.card {
|
||||
background: var(--surface);
|
||||
background: rgba(255,255,255,0.94);
|
||||
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;
|
||||
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; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: #102414;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 999px;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -57,8 +74,8 @@
|
|||
.btn:hover { background: var(--accent-hover); }
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--blue);
|
||||
color: var(--blue);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 6px;
|
||||
|
|
@ -69,7 +86,7 @@
|
|||
.btn-outline:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.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 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.success { color: var(--green); }
|
||||
.resend .msg.error { color: #ef4444; }
|
||||
|
|
@ -89,8 +106,8 @@
|
|||
<div class="card">
|
||||
<span class="icon">🔒</span>
|
||||
<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>
|
||||
<a href="{{.AutheliaURL}}" class="btn">Sign In</a>
|
||||
<p>You need to sign in with your social account before activating your stack.</p>
|
||||
<a href="{{.IdentityURL}}" class="btn">Sign In</a>
|
||||
<div class="resend">
|
||||
<p>Didn't get the setup email?</p>
|
||||
<form id="resend-form">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -7,47 +7,67 @@
|
|||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e4e4e7;
|
||||
--muted: #a1a1aa;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--green: #22c55e;
|
||||
--red: #ef4444;
|
||||
--page: #eff8f8;
|
||||
--surface: #ffffff;
|
||||
--navy: #172f43;
|
||||
--navy-soft: #24465d;
|
||||
--border: #d8e7e5;
|
||||
--text: #14202a;
|
||||
--muted: #5c6f77;
|
||||
--accent: #75d46b;
|
||||
--accent-hover: #5fbd55;
|
||||
--blue: #276f94;
|
||||
--green: #238b4b;
|
||||
--red: #b42318;
|
||||
--warning: #a15c07;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
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;
|
||||
padding: 2rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.header {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 2rem;
|
||||
max-width: 980px;
|
||||
margin: 0 auto 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
background: var(--navy);
|
||||
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 {
|
||||
background: var(--surface);
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -55,7 +75,7 @@
|
|||
padding: 0.5rem 0;
|
||||
}
|
||||
.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 {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.7rem;
|
||||
|
|
@ -63,27 +83,27 @@
|
|||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-active { background: rgba(34,197,94,0.15); color: var(--green); }
|
||||
.badge-inactive { background: rgba(239,68,68,0.15); color: var(--red); }
|
||||
.badge-active { background: rgba(117,212,107,0.22); color: var(--green); }
|
||||
.badge-inactive { background: rgba(180,35,24,0.1); color: var(--red); }
|
||||
.stack-link {
|
||||
display: block;
|
||||
background: var(--bg);
|
||||
background: #f6fbfb;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: 14px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--accent);
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-top: 0.75rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.stack-link:hover { border-color: var(--accent); }
|
||||
.stack-link:hover { border-color: var(--blue); }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: #102414;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 999px;
|
||||
padding: 0.6rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -95,36 +115,36 @@
|
|||
.btn-outline {
|
||||
background: transparent;
|
||||
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; }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--muted); }
|
||||
.empty-state p { margin-bottom: 1.5rem; }
|
||||
.btn-danger {
|
||||
background: rgba(239,68,68,0.15);
|
||||
background: rgba(180,35,24,0.08);
|
||||
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 {
|
||||
background: rgba(234,179,8,0.12);
|
||||
color: #eab308;
|
||||
border: 1px solid rgba(234,179,8,0.25);
|
||||
background: rgba(161,92,7,0.08);
|
||||
color: var(--warning);
|
||||
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; }
|
||||
.divider { border: none; border-top: 1px solid var(--border); margin: 1rem 0; }
|
||||
.security-notice {
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
border: 1px solid rgba(234, 179, 8, 0.25);
|
||||
border-radius: 8px;
|
||||
background: #f6fbfb;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 0.85rem 1.1rem;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
}
|
||||
.security-notice strong { color: #eab308; }
|
||||
.security-notice strong { color: var(--navy); }
|
||||
.version-badge {
|
||||
position: fixed;
|
||||
bottom: 0.75rem;
|
||||
|
|
@ -171,12 +191,17 @@
|
|||
</div>
|
||||
<div class="card">
|
||||
<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">
|
||||
<span class="status-label">Status</span>
|
||||
{{if .StackRunning}}
|
||||
<span class="badge badge-active">Running</span>
|
||||
{{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}}
|
||||
<span class="badge badge-inactive">Not deployed</span>
|
||||
{{end}}
|
||||
|
|
@ -189,7 +214,7 @@
|
|||
{{end}}
|
||||
{{if .StackRunning}}
|
||||
<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)}}
|
||||
<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}}
|
||||
|
|
@ -222,6 +247,17 @@
|
|||
</div>
|
||||
<div class="card">
|
||||
<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">
|
||||
{{if and .SubStatus (eq .SubStatus.Label "Expired")}}
|
||||
<form method="POST" action="/resubscribe" style="margin:0">
|
||||
|
|
@ -242,6 +278,12 @@
|
|||
<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.
|
||||
</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 class="card">
|
||||
<h2>Account Security</h2>
|
||||
|
|
@ -251,8 +293,7 @@
|
|||
best way to ensure your account is never compromised and used without your knowledge.
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="{{.AutheliaURL}}/settings/two-factor-authentication" class="btn btn-outline btn-sm">Set Up Passkey / TOTP</a>
|
||||
<a href="{{.AutheliaURL}}/settings/security" class="btn btn-outline btn-sm">Change Password</a>
|
||||
<a href="{{.IdentityURL}}" class="btn btn-outline btn-sm">Account Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else if .PaidNotActivated}}
|
||||
|
|
@ -269,11 +310,11 @@
|
|||
{{if .User}}
|
||||
<h2>No Active Subscription</h2>
|
||||
<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}}
|
||||
<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>
|
||||
<a href="{{.AutheliaURL}}" class="btn">Sign In</a>
|
||||
<a href="{{.IdentityURL}}" class="btn">Sign In</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,17 +7,22 @@
|
|||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e4e4e7;
|
||||
--muted: #a1a1aa;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--bg: #eff8f8;
|
||||
--surface: #ffffff;
|
||||
--border: #d8e7e5;
|
||||
--text: #14202a;
|
||||
--muted: #5c6f77;
|
||||
--accent: #75d46b;
|
||||
--accent-hover: #5fbd55;
|
||||
--brand: #172f43;
|
||||
--brand-light: #75d46b;
|
||||
}
|
||||
body {
|
||||
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);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
|
|
@ -27,50 +32,89 @@
|
|||
padding: 2rem;
|
||||
}
|
||||
.container { max-width: 480px; width: 100%; }
|
||||
.logo {
|
||||
font-size: 2.5rem;
|
||||
.brand {
|
||||
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;
|
||||
letter-spacing: -0.04em;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.08em;
|
||||
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 {
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 auto 1.5rem;
|
||||
max-width: 24rem;
|
||||
text-align: center;
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
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;
|
||||
}
|
||||
.card h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
.price { font-size: 2rem; font-weight: 700; margin-bottom: 1.5rem; }
|
||||
.card h2 { font-size: 1.35rem; font-weight: 700; margin-bottom: 0.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); }
|
||||
.features { list-style: none; margin-bottom: 2rem; }
|
||||
.features li { padding: 0.4rem 0; color: var(--muted); font-size: 0.95rem; }
|
||||
.features li::before { content: "\2713"; color: var(--accent); font-weight: 700; margin-right: 0.75rem; }
|
||||
.features li { padding: 0.35rem 0; color: var(--muted); font-size: 0.92rem; }
|
||||
.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; }
|
||||
input[type="email"], input[type="text"] {
|
||||
background: var(--bg);
|
||||
form label {
|
||||
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-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 0;
|
||||
padding: 0.55rem 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input[type="email"]:focus, input[type="text"]:focus { border-color: var(--accent); }
|
||||
input[type="email"]::placeholder, input[type="text"]::placeholder { color: var(--muted); }
|
||||
input[type="email"]:focus, input[type="text"]:focus, input[type="tel"]:focus { border-color: var(--accent); }
|
||||
input[type="email"]::placeholder, input[type="text"]::placeholder, input[type="tel"]::placeholder { color: var(--muted); }
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: #102414;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 2px;
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
|
@ -79,18 +123,18 @@
|
|||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: #102414;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 2px;
|
||||
padding: 0.65rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.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 {
|
||||
position: fixed;
|
||||
bottom: 0.75rem;
|
||||
|
|
@ -107,15 +151,18 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">a250.ca</div>
|
||||
<p class="tagline">{{.Tagline}}</p>
|
||||
<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}}
|
||||
<h2>Signups Full</h2>
|
||||
<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)}}
|
||||
<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">
|
||||
{{range .Features}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
|
|
@ -123,7 +170,7 @@
|
|||
{{else if .UseCheckoutForm}}
|
||||
{{if eq .PricingTier 0}}
|
||||
<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}}
|
||||
<h2>Founder Plan</h2>
|
||||
<div class="price">$20 <span>/ year, then $100/month</span></div>
|
||||
|
|
@ -134,12 +181,7 @@
|
|||
<ul class="features">
|
||||
{{range .Features}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
<form method="POST" action="/subscribe">
|
||||
<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>
|
||||
<a href="/checkout" class="btn-primary">Continue with Google</a>
|
||||
{{else}}
|
||||
<h2>Subscribe</h2>
|
||||
<p style="color: var(--muted); margin-bottom: 1rem;">Pricing is being configured. Check back soon.</p>
|
||||
|
|
|
|||
|
|
@ -7,17 +7,22 @@
|
|||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e4e4e7;
|
||||
--muted: #a1a1aa;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--page: #eff8f8;
|
||||
--surface: #ffffff;
|
||||
--navy: #172f43;
|
||||
--border: #d8e7e5;
|
||||
--text: #14202a;
|
||||
--muted: #5c6f77;
|
||||
--accent: #75d46b;
|
||||
--accent-hover: #5fbd55;
|
||||
--green: #238b4b;
|
||||
}
|
||||
body {
|
||||
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);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
|
|
@ -26,15 +31,27 @@
|
|||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.container { max-width: 480px; width: 100%; }
|
||||
.logo { font-size: 2.5rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
|
||||
.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);
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
background: rgba(255,255,255,0.94);
|
||||
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;
|
||||
}
|
||||
.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 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; }
|
||||
|
|
@ -56,6 +73,20 @@
|
|||
.resend .msg { font-size: 0.85rem; margin-top: 0.5rem; }
|
||||
.resend .msg.success { color: var(--green, #22c55e); }
|
||||
.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>
|
||||
{{template "analytics"}}
|
||||
</head>
|
||||
|
|
@ -63,90 +94,14 @@
|
|||
<div class="container">
|
||||
<div class="logo">a250.ca</div>
|
||||
<div class="card">
|
||||
<h2>Check your inbox</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><strong>You'll be required to:</strong></p>
|
||||
<ul>
|
||||
<li>Set a password</li>
|
||||
<li>Enable two-factor authentication or a passkey</li>
|
||||
</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>
|
||||
<h2>You're all set</h2>
|
||||
<p>Your purchase is complete and your workspace is being prepared.</p>
|
||||
<p>Sign in with your social account to open the dashboard and manage your stack.</p>
|
||||
<div class="dashboard-cta">
|
||||
<p class="muted">Ready to continue?</p>
|
||||
<a href="{{.AppURL}}/dashboard" class="btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="{{.AppURL}}/dashboard">Go to Dashboard</a>
|
||||
</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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
# It defines what product/service they receive when they subscribe.
|
||||
# Single-service stack for each paying customer. Simple webapp for testing
|
||||
# routing and auth at https://{{.Domain}}/i/{{.Subdomain}}
|
||||
#
|
||||
# PRODUCT: Gitea — a self-hosted Git service, backed by PostgreSQL.
|
||||
# Each customer gets their own isolated instance at a sub-path.
|
||||
# Traefik: priority 10 ensures /i/{{.Subdomain}} always hits this stack, not
|
||||
# ss-atlas (priority 1). Strip prefix sends e.g. /i/user/foo -> /foo to the app.
|
||||
#
|
||||
# Structure:
|
||||
# web — the application, exposed via Traefik behind Authelia auth
|
||||
# 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.
|
||||
# Template variables (injected by swarm/client.go):
|
||||
# {{.ID}}, {{.Subdomain}}, {{.Domain}}, {{.TraefikDockerNetwork}}
|
||||
# =============================================================================
|
||||
services:
|
||||
web:
|
||||
image: gitea/gitea:1-rootless
|
||||
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"
|
||||
image: louislam/uptime-kuma:2
|
||||
volumes:
|
||||
- gitea_data:/var/lib/gitea
|
||||
- gitea_config:/etc/gitea
|
||||
- app_data:/app/data
|
||||
networks:
|
||||
- traefik_net
|
||||
- backend
|
||||
deploy:
|
||||
replicas: 1
|
||||
labels:
|
||||
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.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.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.services.customer-{{.ID}}-web.loadbalancer.server.port: "3000"
|
||||
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
|
||||
traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3001"
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
networks:
|
||||
traefik_net:
|
||||
external: true
|
||||
name: "atlas_{{.TraefikNetwork}}"
|
||||
backend:
|
||||
driver: overlay
|
||||
name: "{{.TraefikDockerNetwork}}"
|
||||
|
||||
volumes:
|
||||
gitea_data:
|
||||
driver: local
|
||||
gitea_config:
|
||||
driver: local
|
||||
db_data:
|
||||
app_data:
|
||||
driver: local
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -1,452 +1,40 @@
|
|||
#!/bin/sh
|
||||
# Woodpecker production deploy for the Authentik-backed ATLAS stack.
|
||||
|
||||
################################################################################
|
||||
# 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 -eu
|
||||
|
||||
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() {
|
||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')] $1${NC}"
|
||||
printf '[ci-deploy] %s\n' "$*"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR] $1${NC}"
|
||||
fail() {
|
||||
printf '[ci-deploy] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS] $1${NC}"
|
||||
}
|
||||
[ -f "$STACK_FILE" ] || fail "Missing $STACK_FILE"
|
||||
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() {
|
||||
echo -e "${YELLOW}[WARNING] $1${NC}"
|
||||
}
|
||||
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
|
||||
log "Logging into git.nixc.us"
|
||||
printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.nixc.us
|
||||
fi
|
||||
|
||||
debug() {
|
||||
echo -e "${PURPLE}[DEBUG] $1${NC}"
|
||||
}
|
||||
log "Pulling $SS_ATLAS_IMAGE"
|
||||
docker pull "$SS_ATLAS_IMAGE"
|
||||
|
||||
# Cleanup function - runs on script exit
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
debug "Script completed with exit code: $exit_code"
|
||||
exit $exit_code
|
||||
}
|
||||
log "Deploying $STACK_NAME from $STACK_FILE"
|
||||
docker stack deploy --with-registry-auth -c "$STACK_FILE" "$STACK_NAME"
|
||||
|
||||
# Set up cleanup trap
|
||||
trap cleanup EXIT INT TERM
|
||||
if docker service inspect "${STACK_NAME}_ss-atlas" >/dev/null 2>&1; then
|
||||
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
|
||||
retry_command() {
|
||||
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 "$@"
|
||||
log "Current stack tasks"
|
||||
docker stack ps "$STACK_NAME" --no-trunc
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
@ -1,209 +1,190 @@
|
|||
x-authelia-env: &authelia-env
|
||||
X_AUTHELIA_EMAIL: authelia@a250.ca
|
||||
X_AUTHELIA_SITE_NAME: ATLAS
|
||||
X_AUTHELIA_CONFIG_FILTERS: template
|
||||
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
|
||||
TRAEFIK_DOMAIN: bc.a250.ca
|
||||
services:
|
||||
atlas-postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: atlas
|
||||
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:
|
||||
AUTHENTICATION_BACKEND_LDAP_PASSWORD:
|
||||
external: true
|
||||
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET:
|
||||
external: true
|
||||
# TEMPORARILY DISABLED - OIDC provider disabled
|
||||
# IDENTITY_PROVIDERS_OIDC_HMAC_SECRET:
|
||||
# external: true
|
||||
# IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY:
|
||||
# external: true
|
||||
# IDENTITY_PROVIDERS_OIDC_JWKS_KEY:
|
||||
# external: true
|
||||
NOTIFIER_SMTP_PASSWORD:
|
||||
external: true
|
||||
SESSION_SECRET:
|
||||
external: true
|
||||
STORAGE_ENCRYPTION_KEY:
|
||||
external: true
|
||||
# TEMPORARILY DISABLED - OAuth clients disabled
|
||||
# CLIENT_SECRET_HEADSCALE:
|
||||
# external: true
|
||||
# CLIENT_SECRET_HEADADMIN:
|
||||
# external: true
|
||||
# CLIENT_SECRET_PORTAINER:
|
||||
# external: true
|
||||
# TEMPORARILY DISABLED - Gitea OAuth (not ready yet)
|
||||
# CLIENT_SECRET_GITEA:
|
||||
# external: true
|
||||
authentik-postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: authentik
|
||||
volumes:
|
||||
- authentik_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- atlas_internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
|
||||
start_period: 10s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- authentik_redis_data:/data
|
||||
networks:
|
||||
- atlas_internal
|
||||
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
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:
|
||||
default:
|
||||
atlas_internal:
|
||||
driver: overlay
|
||||
attachable: true
|
||||
traefik:
|
||||
external: true
|
||||
ad:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
authelia_config:
|
||||
driver: local
|
||||
authelia_assets:
|
||||
driver: local
|
||||
authelia_redis_data:
|
||||
driver: local
|
||||
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"
|
||||
atlas_archives:
|
||||
atlas_postgres_data:
|
||||
authentik_postgres_data:
|
||||
authentik_redis_data:
|
||||
authentik_media:
|
||||
authentik_templates:
|
||||
|
|
|
|||
220
stack.yml
220
stack.yml
|
|
@ -1,164 +1,30 @@
|
|||
services:
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
atlas-postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: dev_authelia_root
|
||||
MYSQL_DATABASE: authelia
|
||||
MYSQL_USER: authelia
|
||||
MYSQL_PASSWORD: authelia
|
||||
POSTGRES_DB: atlas
|
||||
POSTGRES_USER: atlas
|
||||
POSTGRES_PASSWORD: atlas
|
||||
volumes:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
- atlas_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- authelia_dev
|
||||
- atlas_internal
|
||||
healthcheck:
|
||||
test: [ "CMD", "/usr/local/bin/healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized" ]
|
||||
start_period: 30s
|
||||
test: ["CMD-SHELL", "pg_isready -U atlas -d atlas"]
|
||||
start_period: 10s
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
timeout: 5s
|
||||
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:
|
||||
image: traefik:v3.1
|
||||
image: traefik:v3.6
|
||||
command:
|
||||
- "--api.insecure=true"
|
||||
- "--providers.swarm=true"
|
||||
- "--providers.swarm.endpoint=unix:///var/run/docker.sock"
|
||||
- "--providers.swarm.watch=true"
|
||||
- "--providers.swarm.exposedbydefault=false"
|
||||
- "--providers.swarm.network=atlas_authelia_dev"
|
||||
- "--providers.swarm.network=atlas_atlas_internal"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
||||
|
|
@ -170,7 +36,7 @@ services:
|
|||
volumes:
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
networks:
|
||||
- authelia_dev
|
||||
- atlas_internal
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
|
@ -182,41 +48,40 @@ services:
|
|||
- "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik"
|
||||
- "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:
|
||||
image: atlas-ss-atlas:latest
|
||||
environment:
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-whsec_placeholder}
|
||||
- STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-}
|
||||
- STRIPE_PRICE_ID_FREE=${STRIPE_PRICE_ID_FREE:-}
|
||||
- STRIPE_PRICE_ID_YEAR=${STRIPE_PRICE_ID_YEAR:-}
|
||||
- STRIPE_PRICE_ID_MONTH_100=${STRIPE_PRICE_ID_MONTH_100:-}
|
||||
- STRIPE_PRICE_ID_MONTH_200=${STRIPE_PRICE_ID_MONTH_200:-}
|
||||
- STRIPE_PAYMENT_LINK=${STRIPE_PAYMENT_LINK:-}
|
||||
- FREE_TIER_LIMIT=${FREE_TIER_LIMIT:-10}
|
||||
- YEAR_TIER_LIMIT=${YEAR_TIER_LIMIT:-50}
|
||||
- MAX_SIGNUPS=${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
|
||||
- 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://bc.a250.ca
|
||||
- AUTHELIA_URL=https://bc.a250.ca/login
|
||||
- AUTHELIA_INTERNAL_URL=http://authelia:9091/login
|
||||
- IDENTITY_URL=https://bc.a250.ca/login
|
||||
- DATABASE_URL=postgres://atlas:atlas@atlas-postgres:5432/atlas?sslmode=disable
|
||||
- TRAEFIK_DOMAIN=bc.a250.ca
|
||||
- TRAEFIK_NETWORK=authelia_dev
|
||||
- TRAEFIK_NETWORK=atlas_internal
|
||||
- TRAEFIK_DOCKER_NETWORK=atlas_atlas_internal
|
||||
- CUSTOMER_DOMAIN=bc.a250.ca
|
||||
- TEMPLATE_PATH=/app/templates
|
||||
- ARCHIVE_PATH=/archives
|
||||
- LANDING_TAGLINE=${LANDING_TAGLINE:-Your own workspace, ready in minutes.}
|
||||
- LANDING_FEATURES=${LANDING_FEATURES:-Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime}
|
||||
- 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:
|
||||
- authelia_dev
|
||||
- atlas_internal
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
|
@ -224,31 +89,32 @@ services:
|
|||
- "traefik.http.routers.ss-atlas.entrypoints=websecure"
|
||||
- "traefik.http.routers.ss-atlas.tls=true"
|
||||
- "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.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:
|
||||
image: traefik/whoami
|
||||
networks:
|
||||
- authelia_dev
|
||||
- atlas_internal
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.whoami.rule=Host(`bc.a250.ca`) && PathPrefix(`/whoami`)"
|
||||
- "traefik.http.routers.whoami.entrypoints=websecure"
|
||||
- "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.services.whoami.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
authelia_dev:
|
||||
atlas_internal:
|
||||
driver: overlay
|
||||
attachable: true
|
||||
|
||||
volumes:
|
||||
mariadb_data:
|
||||
redis_data:
|
||||
authelia_data:
|
||||
lldap_data:
|
||||
atlas_archives:
|
||||
atlas_postgres_data:
|
||||
|
|
|
|||
Loading…
Reference in New Issue