forked from Nixius/authelia
Compare commits
No commits in common. "8b3ba3ab5a807d0f5a09606abd1d4f76b4929a58" and "2e8979d4d8eb9ab88503d9ce463378aeeb8d7a96" have entirely different histories.
8b3ba3ab5a
...
2e8979d4d8
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
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,2 +1,5 @@
|
||||||
|
# Authelia – stable/done; keep out of context for ss-atlas and other work
|
||||||
|
|
||||||
|
authelia-dev-config.yml
|
||||||
docker/mariadb/
|
docker/mariadb/
|
||||||
docker/redis/
|
docker/redis/
|
||||||
|
|
@ -32,6 +32,25 @@ steps:
|
||||||
from_secret: DOCKER_REGISTRY_USER
|
from_secret: DOCKER_REGISTRY_USER
|
||||||
DOCKER_REGISTRY_PASSWORD:
|
DOCKER_REGISTRY_PASSWORD:
|
||||||
from_secret: DOCKER_REGISTRY_PASSWORD
|
from_secret: DOCKER_REGISTRY_PASSWORD
|
||||||
|
# Authelia Core Secrets
|
||||||
|
AUTHENTICATION_BACKEND_LDAP_PASSWORD:
|
||||||
|
from_secret: AUTHENTICATION_BACKEND_LDAP_PASSWORD
|
||||||
|
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET:
|
||||||
|
from_secret: IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
|
||||||
|
STORAGE_ENCRYPTION_KEY:
|
||||||
|
from_secret: STORAGE_ENCRYPTION_KEY
|
||||||
|
SESSION_SECRET:
|
||||||
|
from_secret: SESSION_SECRET
|
||||||
|
NOTIFIER_SMTP_PASSWORD:
|
||||||
|
from_secret: NOTIFIER_SMTP_PASSWORD
|
||||||
|
# OIDC Secrets
|
||||||
|
IDENTITY_PROVIDERS_OIDC_HMAC_SECRET:
|
||||||
|
from_secret: IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
|
||||||
|
IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY:
|
||||||
|
from_secret: IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
|
||||||
|
IDENTITY_PROVIDERS_OIDC_JWKS_KEY:
|
||||||
|
from_secret: IDENTITY_PROVIDERS_OIDC_JWKS_KEY
|
||||||
|
# OAuth Client Secrets removed - handled by Docker Swarm secrets at runtime
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
commands:
|
commands:
|
||||||
|
|
@ -54,6 +73,25 @@ steps:
|
||||||
from_secret: REGISTRY_USER
|
from_secret: REGISTRY_USER
|
||||||
REGISTRY_PASSWORD:
|
REGISTRY_PASSWORD:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: REGISTRY_PASSWORD
|
||||||
|
# Authelia Core Secrets
|
||||||
|
AUTHENTICATION_BACKEND_LDAP_PASSWORD:
|
||||||
|
from_secret: AUTHENTICATION_BACKEND_LDAP_PASSWORD
|
||||||
|
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET:
|
||||||
|
from_secret: IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
|
||||||
|
STORAGE_ENCRYPTION_KEY:
|
||||||
|
from_secret: STORAGE_ENCRYPTION_KEY
|
||||||
|
SESSION_SECRET:
|
||||||
|
from_secret: SESSION_SECRET
|
||||||
|
NOTIFIER_SMTP_PASSWORD:
|
||||||
|
from_secret: NOTIFIER_SMTP_PASSWORD
|
||||||
|
# OIDC Secrets
|
||||||
|
IDENTITY_PROVIDERS_OIDC_HMAC_SECRET:
|
||||||
|
from_secret: IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
|
||||||
|
IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY:
|
||||||
|
from_secret: IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
|
||||||
|
IDENTITY_PROVIDERS_OIDC_JWKS_KEY:
|
||||||
|
from_secret: IDENTITY_PROVIDERS_OIDC_JWKS_KEY
|
||||||
|
# OAuth Client Secrets removed - handled by Docker Swarm secrets at runtime
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
commands:
|
commands:
|
||||||
|
|
|
||||||
269
README.md
269
README.md
|
|
@ -1,13 +1,268 @@
|
||||||
# ATLAS
|
<!-- build 5 -->
|
||||||
|
# Authelia with Traefik (ATLAS)
|
||||||
|
## Authentication Traffic LDAP Application Security
|
||||||
|
|
||||||
ATLAS provisions and manages customer workspaces behind Traefik with Authentik-backed identity.
|
A comprehensive, production-ready authentication solution using Authelia with Traefik reverse proxy, featuring automated CI/CD, comprehensive testing, and robust secrets management.
|
||||||
|
|
||||||
## Deploy
|
## 🌟 Features
|
||||||
|
|
||||||
Use the root ship script to build the latest local `ss-atlas` image, push it, and deploy the production stack:
|
- **🔐 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
|
||||||
|
|
||||||
```sh
|
## 🚀 Quick Start
|
||||||
./ship.sh
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- OpenSSL (for secrets generation)
|
||||||
|
- Git with pre-commit hooks support
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
1. **Clone the repository**:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd authelia
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start development environment**:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Access services**:
|
||||||
|
- **Authelia**: http://localhost:9091
|
||||||
|
- **LLDAP Admin**: http://localhost:17170
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: `/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=`
|
||||||
|
|
||||||
|
4. **Run tests**:
|
||||||
|
```bash
|
||||||
|
./tests/precommit.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 Secrets Management
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
Generate production secrets (⚠️ **Use with extreme caution**):
|
||||||
|
```bash
|
||||||
|
./generate-secrets.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The active production stack is defined in `stack.production.yml`. Legacy identity artifacts are preserved under `archives/`.
|
**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!
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
<!-- 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!
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"Home": "Go to App"
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
|
@ -1,9 +1,18 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ss-atlas:
|
mariadb:
|
||||||
build:
|
build:
|
||||||
context: ./docker/ss-atlas/
|
context: ./docker/mariadb/
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.production
|
||||||
args:
|
image: git.nixc.us/a250/authelia:production-mariadb
|
||||||
BUILD_COMMIT: ${BUILD_COMMIT:-unknown}
|
redis:
|
||||||
BUILD_TIME: ${BUILD_TIME:-unknown}
|
build:
|
||||||
image: git.nixc.us/a250/ss-atlas:production
|
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
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
services:
|
services:
|
||||||
|
authelia:
|
||||||
|
build:
|
||||||
|
context: ./docker/authelia/
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: git.nixc.us/a250/authelia:dev-authelia
|
||||||
|
|
||||||
ss-atlas:
|
ss-atlas:
|
||||||
build:
|
build:
|
||||||
context: ./docker/ss-atlas/
|
context: ./docker/ss-atlas/
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ theme: grey
|
||||||
|
|
||||||
server:
|
server:
|
||||||
address: tcp://:9091
|
address: tcp://:9091
|
||||||
asset_path: /config/assets
|
|
||||||
buffers:
|
buffers:
|
||||||
read: 8192
|
read: 8192
|
||||||
write: 8192
|
write: 8192
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
<body>
|
<body>
|
||||||
<h1>{{ .Title }}</h1>
|
<h1>{{ .Title }}</h1>
|
||||||
<p>Hi {{ .DisplayName }},</p>
|
<p>Hi {{ .DisplayName }},</p>
|
||||||
<p>You requested to set or reset your password for your <a href="https://app.a250.ca">a250.ca</a> workspace.</p>
|
<p>You requested to set or reset your password for your <a href="https://bc.a250.ca">a250.ca</a> workspace.</p>
|
||||||
<p>Click the link below to choose your password. You will also need to enable two-factor authentication or a passkey.</p>
|
<p>Click the link below to choose your password. You will also need to enable two-factor authentication or a passkey.</p>
|
||||||
{{ $parts := splitList "token=" .LinkURL }}<p><a href="https://app.a250.ca/login/reset-password/step2?token={{ index $parts 1 }}">{{ .LinkText }}</a></p>
|
{{ $parts := splitList "token=" .LinkURL }}<p><a href="https://bc.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>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>
|
<p style="color:#888;font-size:0.85em;">Requested from {{ .RemoteIP }}.</p>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
Hi {{ .DisplayName }},
|
Hi {{ .DisplayName }},
|
||||||
|
|
||||||
You requested to set or reset your password for your a250.ca workspace (https://app.a250.ca).
|
You requested to set or reset your password for your a250.ca workspace (https://bc.a250.ca).
|
||||||
|
|
||||||
Use the link below to choose your password. You will also need to enable two-factor authentication or a passkey.
|
Use the link below to choose your password. You will also need to enable two-factor authentication or a passkey.
|
||||||
|
|
||||||
{{ $parts := splitList "token=" .LinkURL }}https://app.a250.ca/login/reset-password/step2?token={{ index $parts 1 }}
|
{{ $parts := splitList "token=" .LinkURL }}https://bc.a250.ca/login/reset-password/step2?token={{ index $parts 1 }}
|
||||||
|
|
||||||
If you did not request this, you can safely ignore this email — no changes will be made.
|
If you did not request this, you can safely ignore this email — no changes will be made.
|
||||||
|
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.nixc.us/a250/ss-atlas/internal/accounts"
|
|
||||||
"git.nixc.us/a250/ss-atlas/internal/config"
|
"git.nixc.us/a250/ss-atlas/internal/config"
|
||||||
"git.nixc.us/a250/ss-atlas/internal/handlers"
|
"git.nixc.us/a250/ss-atlas/internal/handlers"
|
||||||
|
"git.nixc.us/a250/ss-atlas/internal/ldap"
|
||||||
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
|
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
|
||||||
"git.nixc.us/a250/ss-atlas/internal/swarm"
|
"git.nixc.us/a250/ss-atlas/internal/swarm"
|
||||||
"git.nixc.us/a250/ss-atlas/internal/version"
|
"git.nixc.us/a250/ss-atlas/internal/version"
|
||||||
|
|
@ -22,14 +22,10 @@ func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
stripeClient := ssstripe.New(cfg)
|
stripeClient := ssstripe.New(cfg)
|
||||||
accountStore, err := accounts.New(context.Background(), cfg.DatabaseURL)
|
ldapClient := ldap.New(cfg)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("account store error: %v", err)
|
|
||||||
}
|
|
||||||
defer accountStore.Close()
|
|
||||||
swarmClient := swarm.New(cfg)
|
swarmClient := swarm.New(cfg)
|
||||||
|
|
||||||
router := handlers.NewRouter(cfg, stripeClient, accountStore, swarmClient)
|
router := handlers.NewRouter(cfg, stripeClient, ldapClient, swarmClient)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,13 @@ go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/lib/pq v1.12.3
|
github.com/go-ldap/ldap/v3 v3.4.10
|
||||||
github.com/stripe/stripe-go/v84 v84.4.0
|
github.com/stripe/stripe-go/v84 v84.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/stretchr/testify v1.8.1 // indirect
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,119 @@
|
||||||
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||||
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
||||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stripe/stripe-go/v84 v84.4.0 h1:JMQMqb+mhW6tns+eYA3G5SZiaoD2ULwN0lZ+kNjWAsY=
|
github.com/stripe/stripe-go/v84 v84.4.0 h1:JMQMqb+mhW6tns+eYA3G5SZiaoD2ULwN0lZ+kNjWAsY=
|
||||||
github.com/stripe/stripe-go/v84 v84.4.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw=
|
github.com/stripe/stripe-go/v84 v84.4.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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,65 +7,68 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
AppURL string
|
AppURL string
|
||||||
IdentityURL string
|
AutheliaURL string
|
||||||
DatabaseURL string
|
AutheliaInternalURL string
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripeWebhookSecret string
|
StripeWebhookSecret string
|
||||||
StripePriceID string // Fallback when tier prices not set
|
StripePriceID string // Fallback when tier prices not set
|
||||||
StripePaymentLink string // Optional: legacy Payment Link for $0
|
StripePaymentLink string // Optional: legacy Payment Link for $0
|
||||||
StripePriceIDFree string // $0/3mo, auto-cancel (first 10)
|
StripePriceIDFree string // $0/3mo, auto-cancel (first 10)
|
||||||
StripePriceIDYear string // $20/year (customers 11–50)
|
StripePriceIDYear string // $20/year (customers 11–50)
|
||||||
StripePriceIDMonth100 string // $100/month (after year for 11–50)
|
StripePriceIDMonth100 string // $100/month (after year for 11–50)
|
||||||
StripePriceIDMonth200 string // $200/month (customers 51+)
|
StripePriceIDMonth200 string // $200/month (customers 51+)
|
||||||
FreeTierLimit int // First N get free tier (default 10)
|
FreeTierLimit int // First N get free tier (default 10)
|
||||||
YearTierLimit int // Up to this count get year tier (default 50)
|
YearTierLimit int // Up to this count get year tier (default 50)
|
||||||
MaxSignups int // Cap on new signups (0 = no limit)
|
MaxSignups int // Cap on new signups (0 = no limit)
|
||||||
DockerHost string
|
LDAPUrl string
|
||||||
TraefikDomain string
|
LDAPAdminDN string
|
||||||
TraefikNetwork string
|
LDAPAdminPassword string
|
||||||
TraefikDockerNetwork string
|
LDAPBaseDN string
|
||||||
TemplatePath string
|
LLDAPHttpURL string
|
||||||
CustomerDomain string
|
DockerHost string
|
||||||
ArchivePath string
|
TraefikDomain string
|
||||||
LandingTagline string // Main tagline under logo
|
TraefikNetwork string
|
||||||
LandingFeatures []string // Bullet points on subscribe card (comma-separated in env)
|
TemplatePath string
|
||||||
AdminSecret string // If set, enables POST /admin/delete-user (X-Admin-Secret header)
|
CustomerDomain string
|
||||||
|
ArchivePath string
|
||||||
|
LandingTagline string // Main tagline under logo
|
||||||
|
LandingFeatures []string // Bullet points on subscribe card (comma-separated in env)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
traefikNetwork := envOrDefault("TRAEFIK_NETWORK", "atlas_internal")
|
|
||||||
traefikDockerNetwork := envOrDefault("TRAEFIK_DOCKER_NETWORK", "atlas_"+traefikNetwork)
|
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Port: envOrDefault("PORT", "8080"),
|
Port: envOrDefault("PORT", "8080"),
|
||||||
AppURL: envOrDefault("APP_URL", "https://bc.a250.ca"),
|
AppURL: envOrDefault("APP_URL", "https://bc.a250.ca"),
|
||||||
IdentityURL: envOrDefault("IDENTITY_URL", "https://bc.a250.ca/login"),
|
AutheliaURL: envOrDefault("AUTHELIA_URL", "https://bc.a250.ca/login"),
|
||||||
DatabaseURL: envOrDefault("DATABASE_URL", "postgres://atlas:atlas@postgres:5432/atlas?sslmode=disable"),
|
AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091/login"),
|
||||||
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
|
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
|
||||||
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
|
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
|
||||||
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
|
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
|
||||||
StripePaymentLink: envOrDefault("STRIPE_PAYMENT_LINK", ""),
|
StripePaymentLink: envOrDefault("STRIPE_PAYMENT_LINK", ""),
|
||||||
StripePriceIDFree: envOrDefault("STRIPE_PRICE_ID_FREE", ""),
|
StripePriceIDFree: envOrDefault("STRIPE_PRICE_ID_FREE", ""),
|
||||||
StripePriceIDYear: envOrDefault("STRIPE_PRICE_ID_YEAR", ""),
|
StripePriceIDYear: envOrDefault("STRIPE_PRICE_ID_YEAR", ""),
|
||||||
StripePriceIDMonth100: envOrDefault("STRIPE_PRICE_ID_MONTH_100", ""),
|
StripePriceIDMonth100: envOrDefault("STRIPE_PRICE_ID_MONTH_100", ""),
|
||||||
StripePriceIDMonth200: envOrDefault("STRIPE_PRICE_ID_MONTH_200", ""),
|
StripePriceIDMonth200: envOrDefault("STRIPE_PRICE_ID_MONTH_200", ""),
|
||||||
FreeTierLimit: envIntOrDefault("FREE_TIER_LIMIT", 10),
|
FreeTierLimit: envIntOrDefault("FREE_TIER_LIMIT", 10),
|
||||||
YearTierLimit: envIntOrDefault("YEAR_TIER_LIMIT", 50),
|
YearTierLimit: envIntOrDefault("YEAR_TIER_LIMIT", 50),
|
||||||
MaxSignups: envIntOrDefault("MAX_SIGNUPS", 0),
|
MaxSignups: envIntOrDefault("MAX_SIGNUPS", 0),
|
||||||
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"),
|
||||||
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
|
LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"),
|
||||||
TraefikNetwork: traefikNetwork,
|
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""),
|
||||||
TraefikDockerNetwork: traefikDockerNetwork,
|
LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"),
|
||||||
TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"),
|
LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"),
|
||||||
CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "bc.a250.ca"),
|
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
||||||
ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"),
|
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"),
|
||||||
LandingTagline: envOrDefault("LANDING_TAGLINE",
|
LandingTagline: envOrDefault("LANDING_TAGLINE",
|
||||||
"Your own workspace, ready in minutes."),
|
"Your own workspace, ready in minutes."),
|
||||||
LandingFeatures: envListOrDefault("LANDING_FEATURES",
|
LandingFeatures: envListOrDefault("LANDING_FEATURES",
|
||||||
[]string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}),
|
[]string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}),
|
||||||
AdminSecret: os.Getenv("ADMIN_SECRET"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,3 +104,4 @@ func envListOrDefault(key string, fallback []string) []string {
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,10 @@ func TestEnvOrDefault(t *testing.T) {
|
||||||
func TestLoadDefaults(t *testing.T) {
|
func TestLoadDefaults(t *testing.T) {
|
||||||
// Clear env vars that Load uses
|
// Clear env vars that Load uses
|
||||||
envKeys := []string{
|
envKeys := []string{
|
||||||
"PORT", "APP_URL", "IDENTITY_URL", "DATABASE_URL", "STRIPE_SECRET_KEY",
|
"PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY",
|
||||||
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "STRIPE_PAYMENT_LINK", "MAX_SIGNUPS",
|
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "STRIPE_PAYMENT_LINK", "MAX_SIGNUPS", "LLDAP_URL",
|
||||||
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TRAEFIK_DOCKER_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
|
"LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN",
|
||||||
|
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
|
||||||
}
|
}
|
||||||
for _, k := range envKeys {
|
for _, k := range envKeys {
|
||||||
os.Unsetenv(k)
|
os.Unsetenv(k)
|
||||||
|
|
@ -48,12 +49,13 @@ func TestLoadDefaults(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{"Port", cfg.Port, "8080"},
|
{"Port", cfg.Port, "8080"},
|
||||||
{"AppURL", cfg.AppURL, "https://bc.a250.ca"},
|
{"AppURL", cfg.AppURL, "https://bc.a250.ca"},
|
||||||
{"IdentityURL", cfg.IdentityURL, "https://bc.a250.ca/login"},
|
{"AutheliaURL", cfg.AutheliaURL, "https://bc.a250.ca/login"},
|
||||||
{"DatabaseURL", cfg.DatabaseURL, "postgres://atlas:atlas@postgres:5432/atlas?sslmode=disable"},
|
{"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"},
|
||||||
{"DockerHost", cfg.DockerHost, "unix:///var/run/docker.sock"},
|
{"DockerHost", cfg.DockerHost, "unix:///var/run/docker.sock"},
|
||||||
{"TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
|
{ "TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
|
||||||
{"TraefikNetwork", cfg.TraefikNetwork, "atlas_internal"},
|
{"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"},
|
||||||
{"TraefikDockerNetwork", cfg.TraefikDockerNetwork, "atlas_atlas_internal"},
|
|
||||||
{"TemplatePath", cfg.TemplatePath, "/app/templates"},
|
{"TemplatePath", cfg.TemplatePath, "/app/templates"},
|
||||||
{"CustomerDomain", cfg.CustomerDomain, "bc.a250.ca"},
|
{"CustomerDomain", cfg.CustomerDomain, "bc.a250.ca"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
|
||||||
acct, identity, err := a.currentAccount(r)
|
remoteUser := r.Header.Get("Remote-User")
|
||||||
if err != nil || acct == nil {
|
if remoteUser == "" {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"IdentityURL": a.cfg.IdentityURL,
|
"AutheliaURL": a.cfg.AutheliaURL,
|
||||||
"AppURL": a.cfg.AppURL,
|
"AppURL": a.cfg.AppURL,
|
||||||
"NeedLogin": true,
|
"NeedLogin": true,
|
||||||
}
|
}
|
||||||
|
|
@ -17,15 +18,16 @@ func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSubscribedAccount(acct) {
|
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers")
|
||||||
|
if inGroup {
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"User": accountDisplay(acct, identity),
|
"User": remoteUser,
|
||||||
"AppURL": a.cfg.AppURL,
|
"AppURL": a.cfg.AppURL,
|
||||||
"Ready": true,
|
"Ready": true,
|
||||||
}
|
}
|
||||||
if err := a.tmpl.ExecuteTemplate(w, "activate.html", data); err != nil {
|
if err := a.tmpl.ExecuteTemplate(w, "activate.html", data); err != nil {
|
||||||
log.Printf("template error: %v", err)
|
log.Printf("template error: %v", err)
|
||||||
|
|
@ -34,39 +36,32 @@ func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleActivatePost(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleActivatePost(w http.ResponseWriter, r *http.Request) {
|
||||||
acct, _, err := a.currentAccount(r)
|
remoteUser := r.Header.Get("Remote-User")
|
||||||
if err != nil || acct == nil {
|
if remoteUser == "" {
|
||||||
http.Error(w, "not authenticated", http.StatusUnauthorized)
|
http.Error(w, "not authenticated", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSubscribedAccount(acct) {
|
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers")
|
||||||
|
if inGroup {
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if acct.StripeCustomerID == "" {
|
|
||||||
http.Error(w, "no paid checkout found", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
inst, err := a.accounts.InstanceByAccountID(r.Context(), acct.ID)
|
if err := a.ldap.AddToGroup(remoteUser, "customers"); err != nil {
|
||||||
if err != nil {
|
log.Printf("activate: group add failed for %s: %v", remoteUser, err)
|
||||||
log.Printf("activate: instance lookup failed for account %d: %v", acct.ID, err)
|
|
||||||
http.Error(w, "activation failed, contact support", http.StatusInternalServerError)
|
http.Error(w, "activation failed, contact support", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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)
|
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)
|
||||||
}
|
}
|
||||||
if err := a.swarm.RestoreVolumes(inst.StackName, a.cfg.ArchivePath); err != nil {
|
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
|
||||||
log.Printf("activate: volume restore failed for %s: %v", inst.StackName, err)
|
log.Printf("activate: stack deploy failed for %s: %v", remoteUser, 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 account %d stack=%s", acct.ID, inst.StackName)
|
log.Printf("activated user %s: group=customers stack=%s", remoteUser, stackName)
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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})
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
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,6 +1,7 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
|
@ -9,10 +10,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
acct, identity, err := a.currentAccount(r)
|
remoteUser := r.Header.Get("Remote-User")
|
||||||
remoteUser := accountDisplay(acct, identity)
|
remoteEmail := r.Header.Get("Remote-Email")
|
||||||
remoteEmail := firstNonEmpty(identity.Email, "")
|
remoteGroups := r.Header.Get("Remote-Groups")
|
||||||
isSubscribed := isSubscribedAccount(acct)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var customerID string
|
var customerID string
|
||||||
stackDeployed := false
|
stackDeployed := false
|
||||||
|
|
@ -20,62 +29,59 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
var subStatus *ssstripe.SubscriptionStatus
|
var subStatus *ssstripe.SubscriptionStatus
|
||||||
paidNotActivated := false
|
paidNotActivated := false
|
||||||
|
|
||||||
if err != nil {
|
if remoteUser != "" {
|
||||||
log.Printf("dashboard: account lookup failed: %v", err)
|
cid, _ := a.ldap.GetStripeCustomerID(remoteUser)
|
||||||
}
|
if cid != "" && !isSubscribed {
|
||||||
if acct != nil && acct.StripeCustomerID != "" && !isSubscribed {
|
paidNotActivated = true
|
||||||
paidNotActivated = true
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var instSlug string
|
if isSubscribed && remoteUser != "" {
|
||||||
var customerDomain string
|
cid, err := a.ldap.GetStripeCustomerID(remoteUser)
|
||||||
if isSubscribed && acct != nil {
|
if err != nil {
|
||||||
customerID = acct.StripeCustomerID
|
log.Printf("dashboard: failed to get stripe customer id for %s: %v", remoteUser, err)
|
||||||
if customerID != "" {
|
}
|
||||||
subStatus = a.stripe.GetCustomerSubscriptionStatus(customerID)
|
customerID = cid
|
||||||
|
if cid != "" {
|
||||||
|
subStatus = a.stripe.GetCustomerSubscriptionStatus(cid)
|
||||||
}
|
}
|
||||||
if subStatus == nil {
|
if subStatus == nil {
|
||||||
subStatus = &ssstripe.SubscriptionStatus{Label: "Active", Badge: "badge-active"}
|
subStatus = &ssstripe.SubscriptionStatus{Label: "Active", Badge: "badge-active"}
|
||||||
}
|
}
|
||||||
|
|
||||||
inst, err := a.accounts.InstanceByAccountID(r.Context(), acct.ID)
|
stackName := fmt.Sprintf("customer-%s", remoteUser)
|
||||||
if err == nil {
|
exists, err := a.swarm.StackExists(stackName)
|
||||||
instSlug = inst.Slug
|
if err != nil {
|
||||||
customerDomain = inst.CustomerDomain
|
log.Printf("dashboard: stack check failed for %s: %v", remoteUser, err)
|
||||||
exists, err := a.swarm.StackExists(inst.StackName)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("dashboard: stack check failed for %s: %v", inst.StackName, err)
|
|
||||||
}
|
|
||||||
stackDeployed = exists
|
|
||||||
if exists {
|
|
||||||
replicas, _ := a.swarm.GetWebReplicas(inst.StackName)
|
|
||||||
stackRunning = replicas > 0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Printf("dashboard: instance lookup failed for account %d: %v", acct.ID, err)
|
|
||||||
}
|
}
|
||||||
|
stackDeployed = exists
|
||||||
|
if exists {
|
||||||
|
replicas, _ := a.swarm.GetWebReplicas(stackName)
|
||||||
|
stackRunning = replicas > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customerDomain := ""
|
||||||
|
if remoteUser != "" {
|
||||||
|
customerDomain, _ = a.ldap.GetCustomerDomain(remoteUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"AppURL": a.cfg.AppURL,
|
"AppURL": a.cfg.AppURL,
|
||||||
"IdentityURL": a.cfg.IdentityURL,
|
"AutheliaURL": a.cfg.AutheliaURL,
|
||||||
"User": remoteUser,
|
"User": remoteUser,
|
||||||
"Email": remoteEmail,
|
"Email": remoteEmail,
|
||||||
"Groups": identity.Groups,
|
"Groups": remoteGroups,
|
||||||
"Domain": a.cfg.TraefikDomain,
|
"Domain": a.cfg.TraefikDomain,
|
||||||
"InstanceSlug": instSlug,
|
|
||||||
"IsSubscribed": isSubscribed,
|
"IsSubscribed": isSubscribed,
|
||||||
"PaidNotActivated": paidNotActivated,
|
"PaidNotActivated": paidNotActivated,
|
||||||
"CustomerID": customerID,
|
"CustomerID": customerID,
|
||||||
"SubStatus": subStatus,
|
"SubStatus": subStatus,
|
||||||
"StackDeployed": stackDeployed,
|
"StackDeployed": stackDeployed,
|
||||||
"StackRunning": stackRunning,
|
"StackRunning": stackRunning,
|
||||||
"CustomerDomain": customerDomain,
|
"CustomerDomain": customerDomain,
|
||||||
"StackError": r.URL.Query().Get("stack_error"),
|
"Commit": version.Commit,
|
||||||
"PortalError": r.URL.Query().Get("portal_error"),
|
"BuildTime": version.BuildTime,
|
||||||
"Linked": r.URL.Query().Get("linked") == "1",
|
|
||||||
"Commit": version.Commit,
|
|
||||||
"BuildTime": version.BuildTime,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {
|
if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -94,3 +94,4 @@ func TestHealthRoute(t *testing.T) {
|
||||||
t.Errorf("GET /health body = %q, want ok", body)
|
t.Errorf("GET /health body = %q, want ok", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"git.nixc.us/a250/ss-atlas/internal/accounts"
|
|
||||||
"git.nixc.us/a250/ss-atlas/internal/config"
|
"git.nixc.us/a250/ss-atlas/internal/config"
|
||||||
|
"git.nixc.us/a250/ss-atlas/internal/ldap"
|
||||||
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
|
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
|
||||||
"git.nixc.us/a250/ss-atlas/internal/swarm"
|
"git.nixc.us/a250/ss-atlas/internal/swarm"
|
||||||
"git.nixc.us/a250/ss-atlas/internal/version"
|
"git.nixc.us/a250/ss-atlas/internal/version"
|
||||||
|
|
@ -16,24 +16,24 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
stripe *ssstripe.Client
|
stripe *ssstripe.Client
|
||||||
accounts *accounts.Store
|
ldap *ldap.Client
|
||||||
swarm *swarm.Client
|
swarm *swarm.Client
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(cfg *config.Config, sc *ssstripe.Client, accountStore *accounts.Store, sw *swarm.Client) http.Handler {
|
func NewRouter(cfg *config.Config, sc *ssstripe.Client, lc *ldap.Client, sw *swarm.Client) http.Handler {
|
||||||
tmplPattern := filepath.Join(cfg.TemplatePath, "pages", "*.html")
|
tmplPattern := filepath.Join(cfg.TemplatePath, "pages", "*.html")
|
||||||
partialsPattern := filepath.Join(cfg.TemplatePath, "partials", "*.html")
|
partialsPattern := filepath.Join(cfg.TemplatePath, "partials", "*.html")
|
||||||
tmpl := template.Must(template.Must(template.ParseGlob(partialsPattern)).ParseGlob(tmplPattern))
|
tmpl := template.Must(template.Must(template.ParseGlob(partialsPattern)).ParseGlob(tmplPattern))
|
||||||
|
|
||||||
app := &App{
|
app := &App{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
stripe: sc,
|
stripe: sc,
|
||||||
accounts: accountStore,
|
ldap: lc,
|
||||||
swarm: sw,
|
swarm: sw,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
@ -42,22 +42,16 @@ func NewRouter(cfg *config.Config, sc *ssstripe.Client, accountStore *accounts.S
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
|
|
||||||
r.Get("/", app.handleLanding)
|
r.Get("/", app.handleLanding)
|
||||||
r.Get("/checkout", app.handleCheckout)
|
|
||||||
r.Get("/success", app.handleSuccess)
|
r.Get("/success", app.handleSuccess)
|
||||||
r.Get("/activate", app.handleActivateGet)
|
r.Get("/activate", app.handleActivateGet)
|
||||||
r.Post("/activate", app.handleActivatePost)
|
r.Post("/activate", app.handleActivatePost)
|
||||||
r.Get("/dashboard", app.handleDashboard)
|
r.Get("/dashboard", app.handleDashboard)
|
||||||
// Instance ownership is checked in Postgres before proxying to the customer stack.
|
|
||||||
r.Handle("/i/{username}", http.HandlerFunc(app.handleInstanceProxy))
|
|
||||||
r.Handle("/i/{username}/*", http.HandlerFunc(app.handleInstanceProxy))
|
|
||||||
r.Post("/stack-manage", app.handleStackManage)
|
r.Post("/stack-manage", app.handleStackManage)
|
||||||
r.Post("/subscribe", app.handleCreateCheckout)
|
r.Post("/subscribe", app.handleCreateCheckout)
|
||||||
r.Post("/resend-reset", app.handleResendReset)
|
r.Post("/resend-reset", app.handleResendReset)
|
||||||
r.Post("/link-stripe-customer", app.handleLinkStripeCustomer)
|
|
||||||
r.Post("/portal", app.handlePortal)
|
r.Post("/portal", app.handlePortal)
|
||||||
r.Post("/resubscribe", app.handleResubscribe)
|
r.Post("/resubscribe", app.handleResubscribe)
|
||||||
r.Post("/webhook/stripe", app.handleWebhook)
|
r.Post("/webhook/stripe", app.handleWebhook)
|
||||||
r.Post("/admin/delete-user", app.handleDeleteUser)
|
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,70 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
|
||||||
acct, _, err := a.currentAccount(r)
|
remoteUser := r.Header.Get("Remote-User")
|
||||||
if err != nil || acct == nil {
|
remoteGroups := r.Header.Get("Remote-Groups")
|
||||||
http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
|
if remoteUser == "" {
|
||||||
|
http.Redirect(w, r, a.cfg.AutheliaURL, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !isSubscribedAccount(acct) {
|
if !contains(remoteGroups, "customers") {
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inst, err := a.accounts.InstanceByAccountID(r.Context(), acct.ID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("stack-manage instance lookup account=%d: %v", acct.ID, err)
|
|
||||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
action := r.FormValue("action")
|
action := r.FormValue("action")
|
||||||
|
stackName := fmt.Sprintf("customer-%s", remoteUser)
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case "stop":
|
case "stop":
|
||||||
if err := a.swarm.ScaleStack(inst.StackName, 0); err != nil {
|
if err := a.swarm.ScaleStack(stackName, 0); err != nil {
|
||||||
log.Printf("stack-manage stop %s: %v", inst.StackName, err)
|
log.Printf("stack-manage stop %s: %v", remoteUser, err)
|
||||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
http.Error(w, "failed to stop stack", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "stopped", false)
|
|
||||||
|
|
||||||
case "start":
|
case "start":
|
||||||
exists, _ := a.swarm.StackExists(inst.StackName)
|
exists, _ := a.swarm.StackExists(stackName)
|
||||||
if !exists {
|
if !exists {
|
||||||
if err := a.swarm.DeployStack(inst.StackName, inst.Slug, a.cfg.TraefikDomain); err != nil {
|
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
|
||||||
log.Printf("stack-manage start (deploy) %s: %v", inst.StackName, err)
|
log.Printf("stack-manage start (deploy) %s: %v", remoteUser, err)
|
||||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
http.Error(w, "failed to start stack", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := a.swarm.ScaleStack(inst.StackName, 1); err != nil {
|
if err := a.swarm.ScaleStack(stackName, 1); err != nil {
|
||||||
log.Printf("stack-manage start (scale) %s: %v", inst.StackName, err)
|
log.Printf("stack-manage start (scale) %s: %v", remoteUser, err)
|
||||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
http.Error(w, "failed to start stack", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true)
|
|
||||||
|
|
||||||
case "restart":
|
case "restart":
|
||||||
if err := a.swarm.RestartStack(inst.StackName); err != nil {
|
if err := a.swarm.RestartStack(stackName); err != nil {
|
||||||
log.Printf("stack-manage restart %s: %v", inst.StackName, err)
|
log.Printf("stack-manage restart %s: %v", remoteUser, err)
|
||||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
http.Error(w, "failed to restart stack", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true)
|
|
||||||
|
|
||||||
case "rebuild":
|
case "rebuild":
|
||||||
if err := a.swarm.DeployStack(inst.StackName, inst.Slug, a.cfg.TraefikDomain); err != nil {
|
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
|
||||||
log.Printf("stack-manage rebuild %s: %v", inst.StackName, err)
|
log.Printf("stack-manage rebuild %s: %v", remoteUser, err)
|
||||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
http.Error(w, "failed to rebuild stack", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "running", true)
|
|
||||||
|
|
||||||
case "destroy":
|
case "destroy":
|
||||||
if err := a.swarm.RemoveStack(inst.StackName); err != nil {
|
if err := a.swarm.RemoveStack(stackName); err != nil {
|
||||||
log.Printf("stack-manage destroy %s: %v", inst.StackName, err)
|
log.Printf("stack-manage destroy %s: %v", remoteUser, err)
|
||||||
redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
|
http.Error(w, "failed to destroy stack", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = a.accounts.UpdateInstanceState(r.Context(), inst.StackName, "destroyed", false)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||||
|
|
@ -82,11 +73,3 @@ func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func redirectWithStackError(w http.ResponseWriter, r *http.Request, baseURL string, err error) {
|
|
||||||
u, _ := url.Parse(baseURL)
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("stack_error", err.Error())
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
http.Redirect(w, r, u.String(), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,37 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.nixc.us/a250/ss-atlas/internal/accounts"
|
|
||||||
"git.nixc.us/a250/ss-atlas/internal/pricing"
|
"git.nixc.us/a250/ss-atlas/internal/pricing"
|
||||||
"git.nixc.us/a250/ss-atlas/internal/stripe"
|
"git.nixc.us/a250/ss-atlas/internal/stripe"
|
||||||
"git.nixc.us/a250/ss-atlas/internal/validation"
|
"git.nixc.us/a250/ss-atlas/internal/validation"
|
||||||
"git.nixc.us/a250/ss-atlas/internal/version"
|
"git.nixc.us/a250/ss-atlas/internal/version"
|
||||||
stripego "github.com/stripe/stripe-go/v84"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
|
||||||
acct, _, _ := a.currentAccount(r)
|
remoteUser := r.Header.Get("Remote-User")
|
||||||
|
|
||||||
if isSubscribedAccount(acct) {
|
if contains(r.Header.Get("Remote-Groups"), "customers") {
|
||||||
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if acct != nil && acct.StripeCustomerID != "" {
|
// Logged-in user who paid but hasn't activated yet — send to activate.
|
||||||
http.Redirect(w, r, a.cfg.AppURL+"/activate", http.StatusSeeOther)
|
if remoteUser != "" {
|
||||||
return
|
custID, _ := a.ldap.GetStripeCustomerID(remoteUser)
|
||||||
|
if custID != "" {
|
||||||
|
http.Redirect(w, r, a.cfg.AppURL+"/activate", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
count, _ := a.accounts.CountCustomers(r.Context())
|
count, _ := a.ldap.CountCustomers()
|
||||||
soldOut := a.cfg.MaxSignups > 0 && count >= a.cfg.MaxSignups
|
soldOut := a.cfg.MaxSignups > 0 && count >= a.cfg.MaxSignups
|
||||||
tier := pricing.ForCustomer(count, a.cfg.FreeTierLimit, a.cfg.YearTierLimit)
|
tier := pricing.ForCustomer(count, a.cfg.FreeTierLimit, a.cfg.YearTierLimit)
|
||||||
|
|
||||||
|
|
@ -55,59 +55,19 @@ func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
acct, _, err := a.currentAccount(r)
|
|
||||||
if err != nil || acct == nil {
|
|
||||||
http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSubscribedAccount(acct) {
|
|
||||||
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if acct.StripeCustomerID != "" {
|
|
||||||
http.Redirect(w, r, a.cfg.AppURL+"/activate", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
count, _ := a.accounts.CountCustomers(r.Context())
|
|
||||||
soldOut := a.cfg.MaxSignups > 0 && count >= a.cfg.MaxSignups
|
|
||||||
if soldOut {
|
|
||||||
http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tier := pricing.ForCustomer(count, a.cfg.FreeTierLimit, a.cfg.YearTierLimit)
|
|
||||||
if err := a.tmpl.ExecuteTemplate(w, "checkout.html", map[string]any{
|
|
||||||
"AppURL": a.cfg.AppURL,
|
|
||||||
"Email": acct.PrimaryEmail,
|
|
||||||
"PricingTier": int(tier),
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("template error: %v", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
|
||||||
acct, identity, err := a.currentAccount(r)
|
|
||||||
if err != nil || acct == nil {
|
|
||||||
http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.cfg.MaxSignups > 0 {
|
if a.cfg.MaxSignups > 0 {
|
||||||
count, err := a.accounts.CountCustomers(r.Context())
|
count, err := a.ldap.CountCustomers()
|
||||||
if err == nil && count >= a.cfg.MaxSignups {
|
if err == nil && count >= a.cfg.MaxSignups {
|
||||||
http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable)
|
http.Error(w, "signup limit reached, try again later", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
email := validation.SanitizeEmail(firstNonEmpty(acct.PrimaryEmail, identity.Email))
|
rawEmail := r.FormValue("email")
|
||||||
|
email := validation.SanitizeEmail(rawEmail)
|
||||||
if email == "" {
|
if email == "" {
|
||||||
http.Error(w, "signed-in account is missing a valid email", http.StatusBadRequest)
|
http.Error(w, "valid email required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||||
|
|
@ -126,8 +86,8 @@ func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
count, _ := a.accounts.CountCustomers(r.Context())
|
count, _ := a.ldap.CountCustomers()
|
||||||
sess, err := a.stripe.CreateCheckoutSession(email, domain, phone, acct.ID, count)
|
sess, err := a.stripe.CreateCheckoutSession(email, domain, phone, count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, stripe.ErrNoPriceForTier) {
|
if errors.Is(err, stripe.ErrNoPriceForTier) {
|
||||||
http.Error(w, "pricing not configured for current tier — set STRIPE_PRICE_ID or tier prices in env", http.StatusServiceUnavailable)
|
http.Error(w, "pricing not configured for current tier — set STRIPE_PRICE_ID or tier prices in env", http.StatusServiceUnavailable)
|
||||||
|
|
@ -149,7 +109,7 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.cfg.MaxSignups > 0 {
|
if a.cfg.MaxSignups > 0 {
|
||||||
count, err := a.accounts.CountCustomers(r.Context())
|
count, err := a.ldap.CountCustomers()
|
||||||
if err == nil && count >= a.cfg.MaxSignups {
|
if err == nil && count >= a.cfg.MaxSignups {
|
||||||
http.Error(w, "signup limit reached, contact support", http.StatusServiceUnavailable)
|
http.Error(w, "signup limit reached, contact support", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
|
|
@ -170,101 +130,68 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
email := sess.CustomerDetails.Email
|
email := sess.CustomerDetails.Email
|
||||||
customerID := sess.Customer.ID
|
customerID := sess.Customer.ID
|
||||||
|
username := sanitizeUsername(email)
|
||||||
phone := ""
|
phone := ""
|
||||||
domain := ""
|
|
||||||
accountID := int64(0)
|
|
||||||
if sess.Metadata != nil {
|
if sess.Metadata != nil {
|
||||||
phone = sess.Metadata["customer_phone"]
|
phone = sess.Metadata["customer_phone"]
|
||||||
domain = sess.Metadata["customer_domain"]
|
|
||||||
accountID, _ = strconv.ParseInt(sess.Metadata["account_id"], 10, 64)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input := accounts.CheckoutInput{
|
result, err := a.ldap.ProvisionUser(username, email, customerID, phone)
|
||||||
AccountID: accountID,
|
|
||||||
Email: email,
|
|
||||||
DisplayName: email,
|
|
||||||
Phone: phone,
|
|
||||||
CustomerDomain: domain,
|
|
||||||
StripeCustomerID: customerID,
|
|
||||||
StripeSubscriptionID: subscriptionIDFromSession(sess),
|
|
||||||
StripeSessionID: sess.ID,
|
|
||||||
}
|
|
||||||
acct, inst, err := a.accounts.UpsertCheckout(r.Context(), input)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("account provision failed for %s: %v", email, err)
|
log.Printf("ldap provision failed for %s: %v", email, err)
|
||||||
http.Error(w, "account creation failed, contact support", http.StatusInternalServerError)
|
http.Error(w, "account creation failed, contact support", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, _ := a.swarm.StackExists(inst.StackName)
|
if sess.Metadata != nil && sess.Metadata["customer_domain"] != "" {
|
||||||
if !exists {
|
if err := a.ldap.SetCustomerDomain(result.Username, sess.Metadata["customer_domain"]); err != nil {
|
||||||
if err := a.swarm.RestoreVolumes(inst.StackName, a.cfg.ArchivePath); err != nil {
|
log.Printf("ldap set customer domain failed for %s: %v", result.Username, err)
|
||||||
log.Printf("success: volume restore failed for %s: %v", inst.StackName, err)
|
|
||||||
}
|
|
||||||
if err := a.swarm.DeployStack(inst.StackName, inst.Slug, a.cfg.TraefikDomain); err != nil {
|
|
||||||
log.Printf("success: stack deploy failed for %s: %v", inst.StackName, err)
|
|
||||||
} else if err := a.accounts.UpdateInstanceState(context.Background(), inst.StackName, "running", true); err != nil {
|
|
||||||
log.Printf("success: update instance state failed for %s: %v", inst.StackName, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := a.tmpl.ExecuteTemplate(w, "success.html", map[string]any{
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLinkStripeCustomer creates a Stripe customer for the current user and saves the ID,
|
// Grant active subscription: add to customers group so dashboard shows subscribed.
|
||||||
// so "Manage Subscription" works. Used when the user is in customers group but has no customer_id (e.g. manual add).
|
if err := a.ldap.AddToGroup(result.Username, "customers"); err != nil {
|
||||||
func (a *App) handleLinkStripeCustomer(w http.ResponseWriter, r *http.Request) {
|
log.Printf("ldap add to customers failed for %s: %v (create group 'customers' in LLDAP admin if missing)", result.Username, err)
|
||||||
acct, identity, err := a.currentAccount(r)
|
}
|
||||||
if err != nil || acct == nil {
|
|
||||||
http.Redirect(w, r, a.cfg.IdentityURL, http.StatusSeeOther)
|
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)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !isSubscribedAccount(acct) {
|
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
// Returning active customer: ensure stack exists, go to dashboard
|
||||||
return
|
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 acct.StripeCustomerID != "" {
|
log.Printf("resubscribe: %s payment verified, redirecting to dashboard", result.Username)
|
||||||
redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "Billing account already linked. Use Manage Subscription below.")
|
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
|
||||||
return
|
|
||||||
}
|
|
||||||
email := strings.TrimSpace(firstNonEmpty(identity.Email, acct.PrimaryEmail))
|
|
||||||
if email == "" {
|
|
||||||
redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "No email on account. Contact support to manage your subscription.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
customerID, err := a.stripe.CreateCustomer(email)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("link-stripe-customer: create customer failed for %s: %v", email, err)
|
|
||||||
redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "Failed to create billing account: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, _, err := a.accounts.UpsertCheckout(r.Context(), accounts.CheckoutInput{
|
|
||||||
Email: email,
|
|
||||||
DisplayName: firstNonEmpty(identity.Name, identity.Username, email),
|
|
||||||
StripeCustomerID: customerID,
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("link-stripe-customer: set account failed for %s: %v", email, err)
|
|
||||||
redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "Billing account created but link failed. Contact support to manage your subscription.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("link-stripe-customer: linked account %d -> Stripe customer %s", acct.ID, customerID)
|
|
||||||
u, _ := url.Parse(a.cfg.AppURL + "/dashboard")
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("linked", "1")
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
http.Redirect(w, r, u.String(), http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
|
||||||
customerID := r.FormValue("customer_id")
|
customerID := r.FormValue("customer_id")
|
||||||
if customerID == "" {
|
if customerID == "" {
|
||||||
redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "No billing account linked. Contact support to manage your subscription.")
|
http.Error(w, "customer_id required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,11 +211,11 @@ func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
|
||||||
func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) {
|
||||||
customerID := r.FormValue("customer_id")
|
customerID := r.FormValue("customer_id")
|
||||||
if customerID == "" {
|
if customerID == "" {
|
||||||
redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "No billing account linked. Contact support to resubscribe.")
|
http.Error(w, "customer_id required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
count, _ := a.accounts.CountCustomers(r.Context())
|
count, _ := a.ldap.CountCustomers()
|
||||||
sess, err := a.stripe.CreateCheckoutForCustomer(customerID, count)
|
sess, err := a.stripe.CreateCheckoutForCustomer(customerID, count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, stripe.ErrNoPriceForTier) {
|
if errors.Is(err, stripe.ErrNoPriceForTier) {
|
||||||
|
|
@ -303,10 +230,6 @@ func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, sess.URL, http.StatusSeeOther)
|
http.Redirect(w, r, sess.URL, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSubscribedAccount(acct *accounts.Account) bool {
|
|
||||||
return acct != nil && acct.SubscriptionStatus == "active"
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeUsername(email string) string {
|
func sanitizeUsername(email string) string {
|
||||||
parts := strings.SplitN(email, "@", 2)
|
parts := strings.SplitN(email, "@", 2)
|
||||||
local := parts[0]
|
local := parts[0]
|
||||||
|
|
@ -328,18 +251,3 @@ func sanitizeUsername(email string) string {
|
||||||
}
|
}
|
||||||
return clean(local) + clean(domain)
|
return clean(local) + clean(domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func redirectWithPortalError(w http.ResponseWriter, r *http.Request, baseURL, message string) {
|
|
||||||
u, _ := url.Parse(baseURL)
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("portal_error", message)
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
http.Redirect(w, r, u.String(), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscriptionIDFromSession(sess *stripego.CheckoutSession) string {
|
|
||||||
if sess == nil || sess.Subscription == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return sess.Subscription.ID
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"git.nixc.us/a250/ss-atlas/internal/accounts"
|
|
||||||
stripego "github.com/stripe/stripe-go/v84"
|
stripego "github.com/stripe/stripe-go/v84"
|
||||||
"github.com/stripe/stripe-go/v84/webhook"
|
"github.com/stripe/stripe-go/v84/webhook"
|
||||||
)
|
)
|
||||||
|
|
@ -47,7 +43,8 @@ func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconciliation backstop: ensures the account, billing link, and instance exist.
|
// Reconciliation backstop: ensures LLDAP user + Stripe ID are set.
|
||||||
|
// Does NOT send password reset — that's the success page's responsibility.
|
||||||
func (a *App) onCheckoutCompleted(event stripego.Event) {
|
func (a *App) onCheckoutCompleted(event stripego.Event) {
|
||||||
var sess stripego.CheckoutSession
|
var sess stripego.CheckoutSession
|
||||||
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
||||||
|
|
@ -57,38 +54,34 @@ func (a *App) onCheckoutCompleted(event stripego.Event) {
|
||||||
|
|
||||||
email := sess.CustomerDetails.Email
|
email := sess.CustomerDetails.Email
|
||||||
customerID := sess.Customer.ID
|
customerID := sess.Customer.ID
|
||||||
|
username := sanitizeUsername(email)
|
||||||
phone := ""
|
phone := ""
|
||||||
domain := ""
|
|
||||||
accountID := int64(0)
|
|
||||||
if sess.Metadata != nil {
|
if sess.Metadata != nil {
|
||||||
phone = sess.Metadata["customer_phone"]
|
phone = sess.Metadata["customer_phone"]
|
||||||
domain = sess.Metadata["customer_domain"]
|
|
||||||
accountID, _ = strconv.ParseInt(sess.Metadata["account_id"], 10, 64)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("webhook: checkout completed email=%s customer=%s account_id=%d", email, customerID, accountID)
|
log.Printf("webhook: checkout completed email=%s customer=%s", email, customerID)
|
||||||
|
|
||||||
if a.cfg.MaxSignups > 0 {
|
if a.cfg.MaxSignups > 0 {
|
||||||
count, err := a.accounts.CountCustomers(context.Background())
|
count, err := a.ldap.CountCustomers()
|
||||||
if err == nil && count >= a.cfg.MaxSignups {
|
if err == nil && count >= a.cfg.MaxSignups {
|
||||||
log.Printf("webhook: signup limit reached (%d), skipping provision for %s", a.cfg.MaxSignups, email)
|
log.Printf("webhook: signup limit reached (%d), skipping provision for %s", a.cfg.MaxSignups, email)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err := a.accounts.UpsertCheckout(context.Background(), accounts.CheckoutInput{
|
if err := a.ldap.EnsureUser(username, email, customerID, phone); err != nil {
|
||||||
AccountID: accountID,
|
log.Printf("webhook: ldap ensure user failed: %v", err)
|
||||||
Email: email,
|
}
|
||||||
DisplayName: email,
|
if err := a.ldap.AddToGroup(username, "customers"); err != nil {
|
||||||
Phone: phone,
|
log.Printf("webhook: ldap add to customers failed for %s: %v", username, err)
|
||||||
CustomerDomain: domain,
|
}
|
||||||
StripeCustomerID: customerID,
|
if sess.Metadata != nil {
|
||||||
StripeSubscriptionID: subscriptionIDFromSession(&sess),
|
if d := sess.Metadata["customer_domain"]; d != "" {
|
||||||
StripeSessionID: sess.ID,
|
if err := a.ldap.SetCustomerDomain(username, d); err != nil {
|
||||||
StripeEventID: event.ID,
|
log.Printf("webhook: ldap set customer domain failed for %s: %v", username, err)
|
||||||
})
|
}
|
||||||
if err != nil {
|
}
|
||||||
log.Printf("webhook: account ensure failed: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subID := ""
|
subID := ""
|
||||||
|
|
@ -119,26 +112,26 @@ func (a *App) onSubscriptionDeleted(event stripego.Event) {
|
||||||
customerID := sub.Customer.ID
|
customerID := sub.Customer.ID
|
||||||
log.Printf("subscription deleted for customer %s", customerID)
|
log.Printf("subscription deleted for customer %s", customerID)
|
||||||
|
|
||||||
_, inst, err := a.accounts.AccountByStripeCustomerID(context.Background(), customerID)
|
username, err := a.ldap.FindUserByStripeID(customerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("could not find account for customer %s: %v", customerID, err)
|
log.Printf("could not find user for customer %s: %v", customerID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.accounts.MarkSubscriptionStatus(context.Background(), customerID, "cancelled"); err != nil && !errors.Is(err, accounts.ErrNotFound) {
|
if err := a.ldap.RemoveFromGroup(username, "customers"); err != nil {
|
||||||
log.Printf("subscription status update failed: %v", err)
|
log.Printf("ldap group remove failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.swarm.ArchiveVolumes(inst.StackName, a.cfg.ArchivePath); err != nil {
|
stackName := "customer-" + username
|
||||||
log.Printf("archive failed for %s: %v", inst.StackName, err)
|
if err := a.swarm.ArchiveVolumes(stackName, a.cfg.ArchivePath); err != nil {
|
||||||
|
log.Printf("archive failed for %s: %v", stackName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.swarm.RemoveStack(inst.StackName); err != nil {
|
if err := a.swarm.RemoveStack(stackName); err != nil {
|
||||||
log.Printf("stack remove failed for %s: %v", inst.StackName, err)
|
log.Printf("stack remove failed for %s: %v", stackName, err)
|
||||||
}
|
}
|
||||||
_ = a.accounts.UpdateInstanceState(context.Background(), inst.StackName, "archived", false)
|
|
||||||
|
|
||||||
log.Printf("deprovisioned stack for customer %s (%s), volumes archived", customerID, inst.Slug)
|
log.Printf("deprovisioned stack for customer %s (%s), volumes archived", customerID, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) onSubscriptionUpdated(event stripego.Event) {
|
func (a *App) onSubscriptionUpdated(event stripego.Event) {
|
||||||
|
|
@ -150,9 +143,6 @@ func (a *App) onSubscriptionUpdated(event stripego.Event) {
|
||||||
|
|
||||||
if sub.Status == stripego.SubscriptionStatusCanceled ||
|
if sub.Status == stripego.SubscriptionStatusCanceled ||
|
||||||
sub.Status == stripego.SubscriptionStatusUnpaid {
|
sub.Status == stripego.SubscriptionStatusUnpaid {
|
||||||
if sub.Customer != nil && sub.Customer.ID != "" {
|
|
||||||
_ = a.accounts.MarkSubscriptionStatus(context.Background(), sub.Customer.ID, string(sub.Status))
|
|
||||||
}
|
|
||||||
log.Printf("subscription %s status=%s, will be cleaned up on deletion", sub.ID, sub.Status)
|
log.Printf("subscription %s status=%s, will be cleaned up on deletion", sub.ID, sub.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -271,31 +271,6 @@ func (c *Client) CountCustomers() (int, error) {
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser removes the user from the customers group (if present) and deletes the LDAP entry.
|
|
||||||
// Call RemoveStack for the customer stack and prune volumes separately if needed.
|
|
||||||
func (c *Client) DeleteUser(username string) error {
|
|
||||||
_ = c.RemoveFromGroup(username, "customers")
|
|
||||||
conn, err := c.connect()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
exists, err := c.userExists(conn, username)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("user %s not found", username)
|
|
||||||
}
|
|
||||||
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
|
|
||||||
delReq := goldap.NewDelRequest(userDN, nil)
|
|
||||||
if err := conn.Del(delReq); err != nil {
|
|
||||||
return fmt.Errorf("ldap delete user %s: %w", username, err)
|
|
||||||
}
|
|
||||||
log.Printf("deleted ldap user %s", username)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) {
|
func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) {
|
||||||
searchReq := goldap.NewSearchRequest(
|
searchReq := goldap.NewSearchRequest(
|
||||||
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN),
|
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN),
|
||||||
|
|
@ -11,7 +11,6 @@ import (
|
||||||
stripego "github.com/stripe/stripe-go/v84"
|
stripego "github.com/stripe/stripe-go/v84"
|
||||||
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
|
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
|
||||||
checkoutsession "github.com/stripe/stripe-go/v84/checkout/session"
|
checkoutsession "github.com/stripe/stripe-go/v84/checkout/session"
|
||||||
"github.com/stripe/stripe-go/v84/customer"
|
|
||||||
"github.com/stripe/stripe-go/v84/subscription"
|
"github.com/stripe/stripe-go/v84/subscription"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -53,7 +52,7 @@ func (c *Client) priceForTier(t pricing.Tier) string {
|
||||||
return c.cfg.StripePriceID
|
return c.cfg.StripePriceID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone string, accountID int64, customerCount int) (*stripego.CheckoutSession, error) {
|
func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone string, customerCount int) (*stripego.CheckoutSession, error) {
|
||||||
t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit)
|
t := pricing.ForCustomer(customerCount, c.cfg.FreeTierLimit, c.cfg.YearTierLimit)
|
||||||
priceID := c.priceForTier(t)
|
priceID := c.priceForTier(t)
|
||||||
if priceID == "" {
|
if priceID == "" {
|
||||||
|
|
@ -77,9 +76,6 @@ func (c *Client) CreateCheckoutSession(email, customerDomain, customerPhone stri
|
||||||
if customerPhone != "" {
|
if customerPhone != "" {
|
||||||
params.AddMetadata("customer_phone", customerPhone)
|
params.AddMetadata("customer_phone", customerPhone)
|
||||||
}
|
}
|
||||||
if accountID > 0 {
|
|
||||||
params.AddMetadata("account_id", strconv.FormatInt(accountID, 10))
|
|
||||||
}
|
|
||||||
params.AddMetadata("pricing_tier", strconv.Itoa(int(t)))
|
params.AddMetadata("pricing_tier", strconv.Itoa(int(t)))
|
||||||
return checkoutsession.New(params)
|
return checkoutsession.New(params)
|
||||||
}
|
}
|
||||||
|
|
@ -106,18 +102,6 @@ func (c *Client) CreateCheckoutForCustomer(customerID string, customerCount int)
|
||||||
return checkoutsession.New(params)
|
return checkoutsession.New(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCustomer creates a Stripe customer with the given email. Returns the customer ID.
|
|
||||||
func (c *Client) CreateCustomer(email string) (string, error) {
|
|
||||||
params := &stripego.CustomerParams{
|
|
||||||
Email: stripego.String(email),
|
|
||||||
}
|
|
||||||
cust, err := customer.New(params)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return cust.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) {
|
func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) {
|
||||||
params := &stripego.BillingPortalSessionParams{
|
params := &stripego.BillingPortalSessionParams{
|
||||||
Customer: stripego.String(customerID),
|
Customer: stripego.String(customerID),
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ func (c *Client) DeployStack(stackName, username, domain string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"ID": username,
|
"ID": username,
|
||||||
"Subdomain": username,
|
"Subdomain": username,
|
||||||
"Domain": domain,
|
"Domain": domain,
|
||||||
"TraefikDockerNetwork": c.cfg.TraefikDockerNetwork,
|
"TraefikNetwork": c.cfg.TraefikNetwork,
|
||||||
}
|
}
|
||||||
|
|
||||||
var rendered bytes.Buffer
|
var rendered bytes.Buffer
|
||||||
|
|
@ -90,34 +90,6 @@ func (c *Client) RemoveStack(stackName string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveStackAndVolumes removes the stack and then any volumes that belonged to it.
|
|
||||||
func (c *Client) RemoveStackAndVolumes(stackName string) error {
|
|
||||||
if err := c.RemoveStack(stackName); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cmd := exec.Command("docker", "volume", "ls", "-q", "--filter", "label=com.docker.stack.namespace="+stackName)
|
|
||||||
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("warn: list volumes for %s: %s", stackName, strings.TrimSpace(string(output)))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, name := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rmCmd := exec.Command("docker", "volume", "rm", name)
|
|
||||||
rmCmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
|
|
||||||
if out, err := rmCmd.CombinedOutput(); err != nil {
|
|
||||||
log.Printf("warn: remove volume %s: %s", name, strings.TrimSpace(string(out)))
|
|
||||||
} else {
|
|
||||||
log.Printf("removed volume %s", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) StackExists(stackName string) (bool, error) {
|
func (c *Client) StackExists(stackName string) (bool, error) {
|
||||||
cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}")
|
cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}")
|
||||||
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
|
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,18 @@
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--page: #eff8f8;
|
--bg: #0f1117;
|
||||||
--surface: #ffffff;
|
--surface: #1a1d27;
|
||||||
--navy: #172f43;
|
--border: #2a2d3a;
|
||||||
--border: #d8e7e5;
|
--text: #e4e4e7;
|
||||||
--text: #14202a;
|
--muted: #a1a1aa;
|
||||||
--muted: #5c6f77;
|
--accent: #6366f1;
|
||||||
--accent: #75d46b;
|
--accent-hover: #818cf8;
|
||||||
--accent-hover: #5fbd55;
|
--green: #22c55e;
|
||||||
--green: #238b4b;
|
|
||||||
--blue: #276f94;
|
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
background:
|
background: var(--bg);
|
||||||
radial-gradient(circle at 18% 12%, rgba(117, 212, 107, 0.18), transparent 28rem),
|
|
||||||
radial-gradient(circle at 82% 4%, rgba(39, 111, 148, 0.16), transparent 26rem),
|
|
||||||
linear-gradient(180deg, #e7f5f5 0%, var(--page) 42%, #f8fbf8 100%);
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -33,37 +28,25 @@
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
.container { max-width: 520px; width: 100%; }
|
.container { max-width: 520px; width: 100%; }
|
||||||
.logo {
|
.logo { font-size: 2rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
|
||||||
display: inline-block;
|
|
||||||
background: var(--navy);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
box-shadow: 0 1rem 2.5rem rgba(23, 47, 67, 0.16);
|
|
||||||
}
|
|
||||||
.subtitle { font-size: 1.1rem; font-weight: 600; margin-bottom: 2rem; }
|
.subtitle { font-size: 1.1rem; font-weight: 600; margin-bottom: 2rem; }
|
||||||
.subtitle-green { color: var(--green); }
|
.subtitle-green { color: var(--green); }
|
||||||
.subtitle-muted { color: var(--muted); }
|
.subtitle-muted { color: var(--muted); }
|
||||||
.card {
|
.card {
|
||||||
background: rgba(255,255,255,0.94);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 24px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.card h2 { color: var(--navy); font-size: 1.35rem; font-weight: 850; letter-spacing: -0.035em; margin-bottom: 0.75rem; }
|
.card h2 { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.75rem; }
|
||||||
.card p { color: var(--muted); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
|
.card p { color: var(--muted); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #102414;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem 2rem;
|
padding: 0.75rem 2rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -74,8 +57,8 @@
|
||||||
.btn:hover { background: var(--accent-hover); }
|
.btn:hover { background: var(--accent-hover); }
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--blue);
|
border: 1px solid var(--accent);
|
||||||
color: var(--blue);
|
color: var(--accent);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
@ -86,7 +69,7 @@
|
||||||
.btn-outline:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn-outline:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.resend { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
.resend { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||||
.resend p { margin-bottom: 0.5rem; font-size: 0.9rem; }
|
.resend p { margin-bottom: 0.5rem; font-size: 0.9rem; }
|
||||||
.resend input { margin-right: 0.5rem; padding: 0.5rem; border-radius: 10px; border: 1px solid var(--border); background: #fff; color: var(--text); width: 12rem; }
|
.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 .msg { font-size: 0.85rem; margin-top: 0.5rem; }
|
.resend .msg { font-size: 0.85rem; margin-top: 0.5rem; }
|
||||||
.resend .msg.success { color: var(--green); }
|
.resend .msg.success { color: var(--green); }
|
||||||
.resend .msg.error { color: #ef4444; }
|
.resend .msg.error { color: #ef4444; }
|
||||||
|
|
@ -106,8 +89,8 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<span class="icon">🔒</span>
|
<span class="icon">🔒</span>
|
||||||
<h2>Sign In First</h2>
|
<h2>Sign In First</h2>
|
||||||
<p>You need to sign in with your social account before activating your stack.</p>
|
<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="{{.IdentityURL}}" class="btn">Sign In</a>
|
<a href="{{.AutheliaURL}}" class="btn">Sign In</a>
|
||||||
<div class="resend">
|
<div class="resend">
|
||||||
<p>Didn't get the setup email?</p>
|
<p>Didn't get the setup email?</p>
|
||||||
<form id="resend-form">
|
<form id="resend-form">
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
<!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,67 +7,47 @@
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--page: #eff8f8;
|
--bg: #0f1117;
|
||||||
--surface: #ffffff;
|
--surface: #1a1d27;
|
||||||
--navy: #172f43;
|
--border: #2a2d3a;
|
||||||
--navy-soft: #24465d;
|
--text: #e4e4e7;
|
||||||
--border: #d8e7e5;
|
--muted: #a1a1aa;
|
||||||
--text: #14202a;
|
--accent: #6366f1;
|
||||||
--muted: #5c6f77;
|
--accent-hover: #818cf8;
|
||||||
--accent: #75d46b;
|
--green: #22c55e;
|
||||||
--accent-hover: #5fbd55;
|
--red: #ef4444;
|
||||||
--blue: #276f94;
|
|
||||||
--green: #238b4b;
|
|
||||||
--red: #b42318;
|
|
||||||
--warning: #a15c07;
|
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
background:
|
background: var(--bg);
|
||||||
radial-gradient(circle at 18% 12%, rgba(117, 212, 107, 0.18), transparent 28rem),
|
|
||||||
radial-gradient(circle at 82% 4%, rgba(39, 111, 148, 0.16), transparent 26rem),
|
|
||||||
linear-gradient(180deg, #e7f5f5 0%, var(--page) 42%, #f8fbf8 100%);
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 1.25rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
max-width: 980px;
|
max-width: 720px;
|
||||||
margin: 0 auto 1.5rem;
|
margin: 0 auto 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.header .logo { font-size: 1rem; font-weight: 800; letter-spacing: -0.03em; }
|
.header .logo { font-size: 1.5rem; font-weight: 800; letter-spacing: -0.04em; }
|
||||||
.header .user-info {
|
.header .user-info {
|
||||||
color: rgba(255,255,255,0.76);
|
display: flex;
|
||||||
font-size: 0.82rem;
|
align-items: center;
|
||||||
overflow: hidden;
|
gap: 1rem;
|
||||||
text-overflow: ellipsis;
|
color: var(--muted);
|
||||||
white-space: nowrap;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.container { max-width: 980px; margin: 0 auto; }
|
.container { max-width: 720px; margin: 0 auto; }
|
||||||
.card {
|
.card {
|
||||||
background: rgba(255,255,255,0.92);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 22px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
|
padding: 1.5rem 2rem;
|
||||||
padding: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.card h2 {
|
|
||||||
color: var(--navy);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.035em;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
.card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; }
|
||||||
.status-row {
|
.status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -75,7 +55,7 @@
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
.status-label { color: var(--muted); font-size: 0.9rem; }
|
.status-label { color: var(--muted); font-size: 0.9rem; }
|
||||||
.status-value { color: var(--navy); font-weight: 700; font-size: 0.9rem; }
|
.status-value { font-weight: 600; font-size: 0.9rem; }
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.2rem 0.7rem;
|
padding: 0.2rem 0.7rem;
|
||||||
|
|
@ -83,27 +63,27 @@
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.badge-active { background: rgba(117,212,107,0.22); color: var(--green); }
|
.badge-active { background: rgba(34,197,94,0.15); color: var(--green); }
|
||||||
.badge-inactive { background: rgba(180,35,24,0.1); color: var(--red); }
|
.badge-inactive { background: rgba(239,68,68,0.15); color: var(--red); }
|
||||||
.stack-link {
|
.stack-link {
|
||||||
display: block;
|
display: block;
|
||||||
background: #f6fbfb;
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
color: var(--blue);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
.stack-link:hover { border-color: var(--blue); }
|
.stack-link:hover { border-color: var(--accent); }
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #102414;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 8px;
|
||||||
padding: 0.6rem 1.25rem;
|
padding: 0.6rem 1.25rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -115,36 +95,36 @@
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--navy);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.btn-outline:hover { border-color: var(--blue); color: var(--blue); background: #f6fbfb; }
|
.btn-outline:hover { border-color: var(--accent); color: var(--accent); background: transparent; }
|
||||||
.actions { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; }
|
.actions { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--muted); }
|
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--muted); }
|
||||||
.empty-state p { margin-bottom: 1.5rem; }
|
.empty-state p { margin-bottom: 1.5rem; }
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: rgba(180,35,24,0.08);
|
background: rgba(239,68,68,0.15);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
border: 1px solid rgba(180,35,24,0.2);
|
border: 1px solid rgba(239,68,68,0.3);
|
||||||
}
|
}
|
||||||
.btn-danger:hover { background: rgba(180,35,24,0.14); color: var(--red); }
|
.btn-danger:hover { background: rgba(239,68,68,0.25); color: var(--red); }
|
||||||
.btn-warning {
|
.btn-warning {
|
||||||
background: rgba(161,92,7,0.08);
|
background: rgba(234,179,8,0.12);
|
||||||
color: var(--warning);
|
color: #eab308;
|
||||||
border: 1px solid rgba(161,92,7,0.22);
|
border: 1px solid rgba(234,179,8,0.25);
|
||||||
}
|
}
|
||||||
.btn-warning:hover { background: rgba(161,92,7,0.14); color: var(--warning); }
|
.btn-warning:hover { background: rgba(234,179,8,0.22); color: #eab308; }
|
||||||
.btn-sm { padding: 0.45rem 0.9rem; font-size: 0.82rem; }
|
.btn-sm { padding: 0.45rem 0.9rem; font-size: 0.82rem; }
|
||||||
.divider { border: none; border-top: 1px solid var(--border); margin: 1rem 0; }
|
.divider { border: none; border-top: 1px solid var(--border); margin: 1rem 0; }
|
||||||
.security-notice {
|
.security-notice {
|
||||||
background: #f6fbfb;
|
background: rgba(234, 179, 8, 0.08);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(234, 179, 8, 0.25);
|
||||||
border-radius: 16px;
|
border-radius: 8px;
|
||||||
padding: 0.85rem 1.1rem;
|
padding: 0.85rem 1.1rem;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.security-notice strong { color: var(--navy); }
|
.security-notice strong { color: #eab308; }
|
||||||
.version-badge {
|
.version-badge {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0.75rem;
|
bottom: 0.75rem;
|
||||||
|
|
@ -191,17 +171,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Your Stack</h2>
|
<h2>Your Stack</h2>
|
||||||
{{if .StackError}}
|
|
||||||
<div class="stack-error" style="background: rgba(180,35,24,0.08); border: 1px solid rgba(180,35,24,0.2); border-radius: 14px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--red);">
|
|
||||||
{{.StackError}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
<div class="status-row">
|
<div class="status-row">
|
||||||
<span class="status-label">Status</span>
|
<span class="status-label">Status</span>
|
||||||
{{if .StackRunning}}
|
{{if .StackRunning}}
|
||||||
<span class="badge badge-active">Running</span>
|
<span class="badge badge-active">Running</span>
|
||||||
{{else if .StackDeployed}}
|
{{else if .StackDeployed}}
|
||||||
<span class="badge" style="background:rgba(161,92,7,0.1);color:var(--warning);">Stopped</span>
|
<span class="badge" style="background:rgba(234,179,8,0.12);color:#eab308;">Stopped</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge badge-inactive">Not deployed</span>
|
<span class="badge badge-inactive">Not deployed</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -214,7 +189,7 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .StackRunning}}
|
{{if .StackRunning}}
|
||||||
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your dedicated environment is accessible at:</p>
|
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your dedicated environment is accessible at:</p>
|
||||||
<a class="stack-link" href="https://{{.Domain}}/i/{{.InstanceSlug}}">{{.Domain}}/i/{{.InstanceSlug}}</a>
|
<a class="stack-link" href="https://{{.Domain}}/i/{{.User}}">{{.Domain}}/i/{{.User}}</a>
|
||||||
{{else if and .StackDeployed (not .StackRunning)}}
|
{{else if and .StackDeployed (not .StackRunning)}}
|
||||||
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your stack is stopped. Start it to access your environment.</p>
|
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your stack is stopped. Start it to access your environment.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -247,17 +222,6 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Manage</h2>
|
<h2>Manage</h2>
|
||||||
{{if .Linked}}
|
|
||||||
<div class="portal-success" style="background: rgba(117,212,107,0.18); border: 1px solid rgba(117,212,107,0.35); border-radius: 14px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--green);">
|
|
||||||
You can use Manage Subscription below.
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .PortalError}}
|
|
||||||
<div class="portal-error" style="background: rgba(180,35,24,0.08); border: 1px solid rgba(180,35,24,0.2); border-radius: 14px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--red);">
|
|
||||||
{{.PortalError}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .CustomerID}}
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{{if and .SubStatus (eq .SubStatus.Label "Expired")}}
|
{{if and .SubStatus (eq .SubStatus.Label "Expired")}}
|
||||||
<form method="POST" action="/resubscribe" style="margin:0">
|
<form method="POST" action="/resubscribe" style="margin:0">
|
||||||
|
|
@ -278,12 +242,6 @@
|
||||||
<p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;">
|
<p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;">
|
||||||
No refunds for the current billing period. Access continues until the end of your paid month.
|
No refunds for the current billing period. Access continues until the end of your paid month.
|
||||||
</p>
|
</p>
|
||||||
{{else}}
|
|
||||||
<p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 0.75rem;">Update payment method or cancel from the Stripe portal.</p>
|
|
||||||
<form method="POST" action="/link-stripe-customer" style="margin:0">
|
|
||||||
<button type="submit" class="btn btn-sm">Manage Subscription</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Account Security</h2>
|
<h2>Account Security</h2>
|
||||||
|
|
@ -293,7 +251,8 @@
|
||||||
best way to ensure your account is never compromised and used without your knowledge.
|
best way to ensure your account is never compromised and used without your knowledge.
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="{{.IdentityURL}}" class="btn btn-outline btn-sm">Account Settings</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else if .PaidNotActivated}}
|
{{else if .PaidNotActivated}}
|
||||||
|
|
@ -310,11 +269,11 @@
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<h2>No Active Subscription</h2>
|
<h2>No Active Subscription</h2>
|
||||||
<p>You're signed in as <strong>{{.User}}</strong>, but you don't have an active subscription.</p>
|
<p>You're signed in as <strong>{{.User}}</strong>, but you don't have an active subscription.</p>
|
||||||
<a href="/checkout" class="btn">Subscribe Now</a>
|
<a href="/" class="btn">Subscribe Now</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2>Sign In Required</h2>
|
<h2>Sign In Required</h2>
|
||||||
<p>Check your email for the password setup link. Once you've set your password and signed in, you can activate your stack here.</p>
|
<p>Check your email for the password setup link. Once you've set your password and signed in, you can activate your stack here.</p>
|
||||||
<a href="{{.IdentityURL}}" class="btn">Sign In</a>
|
<a href="{{.AutheliaURL}}" class="btn">Sign In</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,17 @@
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--bg: #eff8f8;
|
--bg: #0f1117;
|
||||||
--surface: #ffffff;
|
--surface: #1a1d27;
|
||||||
--border: #d8e7e5;
|
--border: #2a2d3a;
|
||||||
--text: #14202a;
|
--text: #e4e4e7;
|
||||||
--muted: #5c6f77;
|
--muted: #a1a1aa;
|
||||||
--accent: #75d46b;
|
--accent: #6366f1;
|
||||||
--accent-hover: #5fbd55;
|
--accent-hover: #818cf8;
|
||||||
--brand: #172f43;
|
|
||||||
--brand-light: #75d46b;
|
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
background:
|
background: var(--bg);
|
||||||
radial-gradient(circle at 18% 12%, rgba(117, 212, 107, 0.18), transparent 28rem),
|
|
||||||
radial-gradient(circle at 82% 4%, rgba(39, 111, 148, 0.16), transparent 26rem),
|
|
||||||
linear-gradient(180deg, #e7f5f5 0%, var(--bg) 42%, #f8fbf8 100%);
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -32,89 +27,50 @@
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
.container { max-width: 480px; width: 100%; }
|
.container { max-width: 480px; width: 100%; }
|
||||||
.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.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 {
|
.logo {
|
||||||
color: var(--brand);
|
font-size: 2.5rem;
|
||||||
font-size: 1rem;
|
font-weight: 800;
|
||||||
font-weight: 700;
|
letter-spacing: -0.04em;
|
||||||
letter-spacing: -0.03em;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.tagline {
|
.tagline {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 auto 1.5rem;
|
|
||||||
max-width: 24rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 24px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
.card h2 { font-size: 1.35rem; font-weight: 700; margin-bottom: 0.5rem; }
|
.card h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
.price { font-size: 1.8rem; font-weight: 700; margin-bottom: 1.25rem; }
|
.price { font-size: 2rem; font-weight: 700; margin-bottom: 1.5rem; }
|
||||||
.price span { font-size: 0.9rem; font-weight: 400; color: var(--muted); }
|
.price span { font-size: 0.9rem; font-weight: 400; color: var(--muted); }
|
||||||
.features { list-style: none; margin-bottom: 2rem; }
|
.features { list-style: none; margin-bottom: 2rem; }
|
||||||
.features li { padding: 0.35rem 0; color: var(--muted); font-size: 0.92rem; }
|
.features li { padding: 0.4rem 0; color: var(--muted); font-size: 0.95rem; }
|
||||||
.features li::before { content: "\2713"; color: var(--brand-light); font-weight: 700; margin-right: 0.75rem; }
|
.features li::before { content: "\2713"; color: var(--accent); font-weight: 700; margin-right: 0.75rem; }
|
||||||
form { display: flex; flex-direction: column; gap: 0.75rem; }
|
form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
form label {
|
input[type="email"], input[type="text"] {
|
||||||
display: block;
|
background: var(--bg);
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
margin-bottom: -0.25rem;
|
|
||||||
}
|
|
||||||
input[type="email"], input[type="text"], input[type="tel"] {
|
|
||||||
width: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0;
|
border-radius: 8px;
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.75rem 1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
input[type="email"]:focus, input[type="text"]:focus, input[type="tel"]:focus { border-color: var(--accent); }
|
input[type="email"]:focus, input[type="text"]:focus { border-color: var(--accent); }
|
||||||
input[type="email"]::placeholder, input[type="text"]::placeholder, input[type="tel"]::placeholder { color: var(--muted); }
|
input[type="email"]::placeholder, input[type="text"]::placeholder { color: var(--muted); }
|
||||||
button {
|
button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #102414;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 2px;
|
border-radius: 8px;
|
||||||
padding: 0.65rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|
@ -123,18 +79,18 @@
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #102414;
|
color: #fff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 2px;
|
border-radius: 8px;
|
||||||
padding: 0.65rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||||
.footer a { color: var(--brand); font-weight: 700; text-decoration: none; }
|
.footer a { color: var(--accent); text-decoration: none; }
|
||||||
.version-badge {
|
.version-badge {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0.75rem;
|
bottom: 0.75rem;
|
||||||
|
|
@ -151,18 +107,15 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="logo">a250.ca</div>
|
||||||
|
<p class="tagline">{{.Tagline}}</p>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="brand">
|
|
||||||
<div class="brand-mark">A</div>
|
|
||||||
<div class="logo">a250.ca</div>
|
|
||||||
</div>
|
|
||||||
<p class="tagline">{{.Tagline}}</p>
|
|
||||||
{{if .SoldOut}}
|
{{if .SoldOut}}
|
||||||
<h2>Signups Full</h2>
|
<h2>Signups Full</h2>
|
||||||
<p style="color: var(--muted); margin-bottom: 1rem;">We've reached our limit for new signups. Check back later.</p>
|
<p style="color: var(--muted); margin-bottom: 1rem;">We've reached our limit for new signups. Check back later.</p>
|
||||||
{{else if and .StripePaymentLink (eq .PricingTier 0) (not .UseCheckoutForm)}}
|
{{else if and .StripePaymentLink (eq .PricingTier 0) (not .UseCheckoutForm)}}
|
||||||
<h2>Free Plan</h2>
|
<h2>Free Plan</h2>
|
||||||
<div class="price">$0 <span>/month for max 3 months</span></div>
|
<div class="price">$0 <span>/ one-time</span></div>
|
||||||
<ul class="features">
|
<ul class="features">
|
||||||
{{range .Features}}<li>{{.}}</li>{{end}}
|
{{range .Features}}<li>{{.}}</li>{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -170,7 +123,7 @@
|
||||||
{{else if .UseCheckoutForm}}
|
{{else if .UseCheckoutForm}}
|
||||||
{{if eq .PricingTier 0}}
|
{{if eq .PricingTier 0}}
|
||||||
<h2>Launch Offer</h2>
|
<h2>Launch Offer</h2>
|
||||||
<div class="price">$0 <span>/month for max 3 months</span></div>
|
<div class="price">$0 <span>/ 3 months, then ends</span></div>
|
||||||
{{else if eq .PricingTier 1}}
|
{{else if eq .PricingTier 1}}
|
||||||
<h2>Founder Plan</h2>
|
<h2>Founder Plan</h2>
|
||||||
<div class="price">$20 <span>/ year, then $100/month</span></div>
|
<div class="price">$20 <span>/ year, then $100/month</span></div>
|
||||||
|
|
@ -181,7 +134,12 @@
|
||||||
<ul class="features">
|
<ul class="features">
|
||||||
{{range .Features}}<li>{{.}}</li>{{end}}
|
{{range .Features}}<li>{{.}}</li>{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
<a href="/checkout" class="btn-primary">Continue with Google</a>
|
<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>
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2>Subscribe</h2>
|
<h2>Subscribe</h2>
|
||||||
<p style="color: var(--muted); margin-bottom: 1rem;">Pricing is being configured. Check back soon.</p>
|
<p style="color: var(--muted); margin-bottom: 1rem;">Pricing is being configured. Check back soon.</p>
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,17 @@
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--page: #eff8f8;
|
--bg: #0f1117;
|
||||||
--surface: #ffffff;
|
--surface: #1a1d27;
|
||||||
--navy: #172f43;
|
--border: #2a2d3a;
|
||||||
--border: #d8e7e5;
|
--text: #e4e4e7;
|
||||||
--text: #14202a;
|
--muted: #a1a1aa;
|
||||||
--muted: #5c6f77;
|
--accent: #6366f1;
|
||||||
--accent: #75d46b;
|
--accent-hover: #818cf8;
|
||||||
--accent-hover: #5fbd55;
|
|
||||||
--green: #238b4b;
|
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
background:
|
background: var(--bg);
|
||||||
radial-gradient(circle at 18% 12%, rgba(117, 212, 107, 0.18), transparent 28rem),
|
|
||||||
radial-gradient(circle at 82% 4%, rgba(39, 111, 148, 0.16), transparent 26rem),
|
|
||||||
linear-gradient(180deg, #e7f5f5 0%, var(--page) 42%, #f8fbf8 100%);
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -31,27 +26,15 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
.container { max-width: 520px; width: 100%; }
|
.container { max-width: 480px; width: 100%; }
|
||||||
.logo {
|
.logo { font-size: 2.5rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
|
||||||
display: inline-block;
|
|
||||||
background: var(--navy);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
box-shadow: 0 1rem 2.5rem rgba(23, 47, 67, 0.16);
|
|
||||||
}
|
|
||||||
.card {
|
.card {
|
||||||
background: rgba(255,255,255,0.94);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 24px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 1.25rem 3rem rgba(23, 47, 67, 0.08);
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
.card h2 { color: var(--navy); font-size: 1.6rem; font-weight: 850; letter-spacing: -0.04em; margin-bottom: 1rem; }
|
.card h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; }
|
||||||
.card p { color: var(--muted); line-height: 1.6; margin-bottom: 1rem; }
|
.card p { color: var(--muted); line-height: 1.6; margin-bottom: 1rem; }
|
||||||
.card ul { color: var(--muted); line-height: 1.7; margin: 1rem 0; padding-left: 1.25rem; }
|
.card ul { color: var(--muted); line-height: 1.7; margin: 1rem 0; padding-left: 1.25rem; }
|
||||||
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||||
|
|
@ -73,20 +56,6 @@
|
||||||
.resend .msg { font-size: 0.85rem; margin-top: 0.5rem; }
|
.resend .msg { font-size: 0.85rem; margin-top: 0.5rem; }
|
||||||
.resend .msg.success { color: var(--green, #22c55e); }
|
.resend .msg.success { color: var(--green, #22c55e); }
|
||||||
.resend .msg.error { color: #ef4444; }
|
.resend .msg.error { color: #ef4444; }
|
||||||
.dashboard-cta { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
|
||||||
.dashboard-cta .muted { color: var(--muted); font-size: 0.9rem; margin-bottom: 0.5rem; }
|
|
||||||
.dashboard-cta .btn-primary {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #102414;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.6rem 1.25rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 999px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.dashboard-cta .btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
</style>
|
</style>
|
||||||
{{template "analytics"}}
|
{{template "analytics"}}
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -94,14 +63,90 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="logo">a250.ca</div>
|
<div class="logo">a250.ca</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>You're all set</h2>
|
<h2>Check your inbox</h2>
|
||||||
<p>Your purchase is complete and your workspace is being prepared.</p>
|
<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>Sign in with your social account to open the dashboard and manage your stack.</p>
|
<p><strong>You'll be required to:</strong></p>
|
||||||
<div class="dashboard-cta">
|
<ul>
|
||||||
<p class="muted">Ready to continue?</p>
|
<li>Set a password</li>
|
||||||
<a href="{{.AppURL}}/dashboard" class="btn-primary">Go to Dashboard</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<a href="{{.AppURL}}/dashboard">Go to Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .Username}}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var form = document.getElementById('resend-form');
|
||||||
|
var btn = document.getElementById('resend-btn');
|
||||||
|
var msg = document.getElementById('resend-msg');
|
||||||
|
var cooldown = 60, interval;
|
||||||
|
|
||||||
|
function setCooldown(sec) {
|
||||||
|
cooldown = sec;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Resend (' + sec + 's)';
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
interval = setInterval(function() {
|
||||||
|
cooldown--;
|
||||||
|
if (cooldown <= 0) {
|
||||||
|
clearInterval(interval);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Resend';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.textContent = 'Resend (' + cooldown + 's)';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCooldown(60);
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
btn.disabled = true;
|
||||||
|
msg.textContent = '';
|
||||||
|
fetch('/resend-reset', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new FormData(form),
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
}).then(function(r) {
|
||||||
|
return r.json().then(function(data) {
|
||||||
|
if (data.ok) {
|
||||||
|
msg.textContent = data.message;
|
||||||
|
msg.className = 'msg success';
|
||||||
|
setCooldown(60);
|
||||||
|
} else if (data.retry_after_seconds) {
|
||||||
|
msg.textContent = data.error + ' Try again in ' + data.retry_after_seconds + 's.';
|
||||||
|
msg.className = 'msg error';
|
||||||
|
setCooldown(data.retry_after_seconds);
|
||||||
|
} else {
|
||||||
|
msg.textContent = data.error;
|
||||||
|
msg.className = 'msg error';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Resend';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(function() {
|
||||||
|
msg.textContent = 'Something went wrong. Please try again.';
|
||||||
|
msg.className = 'msg error';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Resend';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,88 @@
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CUSTOMER STACK TEMPLATE — Uptime Kuma
|
# CUSTOMER STACK TEMPLATE — Gitea + PostgreSQL
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Single-service stack for each paying customer. Simple webapp for testing
|
# This is the Docker Swarm stack deployed for each paying customer.
|
||||||
# routing and auth at https://{{.Domain}}/i/{{.Subdomain}}
|
# It defines what product/service they receive when they subscribe.
|
||||||
#
|
#
|
||||||
# Traefik: priority 10 ensures /i/{{.Subdomain}} always hits this stack, not
|
# PRODUCT: Gitea — a self-hosted Git service, backed by PostgreSQL.
|
||||||
# ss-atlas (priority 1). Strip prefix sends e.g. /i/user/foo -> /foo to the app.
|
# Each customer gets their own isolated instance at a sub-path.
|
||||||
#
|
#
|
||||||
# Template variables (injected by swarm/client.go):
|
# Structure:
|
||||||
# {{.ID}}, {{.Subdomain}}, {{.Domain}}, {{.TraefikDockerNetwork}}
|
# 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.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: louislam/uptime-kuma:2
|
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"
|
||||||
volumes:
|
volumes:
|
||||||
- app_data:/app/data
|
- gitea_data:/var/lib/gitea
|
||||||
|
- gitea_config:/etc/gitea
|
||||||
networks:
|
networks:
|
||||||
- traefik_net
|
- traefik_net
|
||||||
|
- backend
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
labels:
|
labels:
|
||||||
traefik.enable: "true"
|
traefik.enable: "true"
|
||||||
traefik.docker.network: "{{.TraefikDockerNetwork}}"
|
traefik.docker.network: "atlas_{{.TraefikNetwork}}"
|
||||||
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Domain}}`) && PathPrefix(`/i/{{.Subdomain}}`)"
|
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Domain}}`) && PathPrefix(`/i/{{.Subdomain}}`)"
|
||||||
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure"
|
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure"
|
||||||
traefik.http.routers.customer-{{.ID}}-web.priority: "10"
|
traefik.http.routers.customer-{{.ID}}-web.priority: "2"
|
||||||
traefik.http.routers.customer-{{.ID}}-web.tls: "true"
|
traefik.http.routers.customer-{{.ID}}-web.tls: "true"
|
||||||
traefik.http.routers.customer-{{.ID}}-web.middlewares: "strip-customer-{{.ID}}@swarm,authentik@swarm"
|
traefik.http.routers.customer-{{.ID}}-web.middlewares: "strip-customer-{{.ID}}@swarm,authelia-auth@swarm"
|
||||||
traefik.http.middlewares.strip-customer-{{.ID}}.stripprefix.prefixes: "/i/{{.Subdomain}}"
|
traefik.http.middlewares.strip-customer-{{.ID}}.stripprefix.prefixes: "/i/{{.Subdomain}}"
|
||||||
traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3001"
|
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
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik_net:
|
traefik_net:
|
||||||
external: true
|
external: true
|
||||||
name: "{{.TraefikDockerNetwork}}"
|
name: "atlas_{{.TraefikNetwork}}"
|
||||||
|
backend:
|
||||||
|
driver: overlay
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
app_data:
|
gitea_data:
|
||||||
|
driver: local
|
||||||
|
gitea_config:
|
||||||
|
driver: local
|
||||||
|
db_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
||||||
|
|
@ -28,6 +28,7 @@ Your Woodpecker CI vault must contain **12 total secrets** for proper Authelia d
|
||||||
| `CLIENT_SECRET_HEADSCALE` | Headscale VPN OIDC client | `./generate-secrets.sh` |
|
| `CLIENT_SECRET_HEADSCALE` | Headscale VPN OIDC client | `./generate-secrets.sh` |
|
||||||
| `CLIENT_SECRET_HEADADMIN` | Headscale admin OIDC client | `./generate-secrets.sh` |
|
| `CLIENT_SECRET_HEADADMIN` | Headscale admin OIDC client | `./generate-secrets.sh` |
|
||||||
| `CLIENT_SECRET_PORTAINER` | Portainer OAuth client | `./scripts/generate-oauth-secrets.sh` |
|
| `CLIENT_SECRET_PORTAINER` | Portainer OAuth client | `./scripts/generate-oauth-secrets.sh` |
|
||||||
|
| `CLIENT_SECRET_GITEA` | Gitea OAuth client | `./scripts/generate-oauth-secrets.sh` |
|
||||||
|
|
||||||
## 🚀 Setup Process
|
## 🚀 Setup Process
|
||||||
|
|
||||||
|
|
@ -63,6 +64,7 @@ export WOODPECKER_TOKEN=your-api-token
|
||||||
|
|
||||||
# Update all secrets (example commands)
|
# Update all secrets (example commands)
|
||||||
woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)"
|
woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)"
|
||||||
|
woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --value "$(cat secrets/clients/gitea-secret.txt)"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔄 Secret Rotation
|
## 🔄 Secret Rotation
|
||||||
|
|
@ -83,7 +85,7 @@ woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER -
|
||||||
# Regenerate OAuth client secrets only
|
# Regenerate OAuth client secrets only
|
||||||
./scripts/generate-oauth-secrets.sh
|
./scripts/generate-oauth-secrets.sh
|
||||||
|
|
||||||
# Update CLIENT_SECRET_PORTAINER in vault
|
# Update CLIENT_SECRET_PORTAINER and CLIENT_SECRET_GITEA in vault
|
||||||
# Deploy when convenient
|
# Deploy when convenient
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# OAuth/OIDC Client Setup Guide
|
# OAuth/OIDC Client Setup Guide
|
||||||
|
|
||||||
This guide covers setting up OAuth/OIDC authentication for services like Portainer using Authelia as the identity provider.
|
This guide covers setting up OAuth/OIDC authentication for services like Portainer and Gitea using Authelia as the identity provider.
|
||||||
|
|
||||||
## 🔧 Overview
|
## 🔧 Overview
|
||||||
|
|
||||||
|
|
@ -27,6 +27,10 @@ Add these to your Woodpecker CI vault:
|
||||||
- **Variable**: `CLIENT_SECRET_PORTAINER`
|
- **Variable**: `CLIENT_SECRET_PORTAINER`
|
||||||
- **Value**: Generated from `secrets/clients/portainer-secret.txt`
|
- **Value**: Generated from `secrets/clients/portainer-secret.txt`
|
||||||
|
|
||||||
|
#### Gitea OAuth
|
||||||
|
- **Variable**: `CLIENT_SECRET_GITEA`
|
||||||
|
- **Value**: Generated from `secrets/clients/gitea-secret.txt`
|
||||||
|
|
||||||
## 📱 Client Configurations
|
## 📱 Client Configurations
|
||||||
|
|
||||||
### Portainer OAuth Setup
|
### Portainer OAuth Setup
|
||||||
|
|
@ -71,6 +75,39 @@ Once OAuth is working, remove middleware protection:
|
||||||
# traefik.http.routers.portainer.middlewares: authelia_authelia
|
# traefik.http.routers.portainer.middlewares: authelia_authelia
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Gitea OAuth Setup
|
||||||
|
|
||||||
|
#### 1. Authelia Configuration
|
||||||
|
Already configured in `docker/authelia/config/configuration.oidc.clients.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- client_id: gitea
|
||||||
|
client_name: Gitea
|
||||||
|
client_secret: {{ secret "/run/secrets/CLIENT_SECRET_GITEA" }}
|
||||||
|
public: false
|
||||||
|
authorization_policy: one_factor
|
||||||
|
consent_mode: implicit
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
- groups
|
||||||
|
redirect_uris:
|
||||||
|
- https://git.{{ env "TRAEFIK_DOMAIN" }}/user/oauth2/authelia/callback
|
||||||
|
userinfo_signed_response_alg: none
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Gitea OAuth Settings
|
||||||
|
Configure in Gitea → Site Administration → Authentication Sources:
|
||||||
|
|
||||||
|
- **Authentication Type**: OAuth2
|
||||||
|
- **Authentication Name**: `Authelia`
|
||||||
|
- **OAuth2 Provider**: OpenID Connect
|
||||||
|
- **Client ID**: `gitea`
|
||||||
|
- **Client Secret**: `<from CI vault>`
|
||||||
|
- **OpenID Connect Auto Discovery URL**: `https://login.a250.ca/.well-known/openid_configuration`
|
||||||
|
- **Icon URL**: `https://login.a250.ca/static/media/logo.png` (optional)
|
||||||
|
|
||||||
## 🔄 Deployment Process
|
## 🔄 Deployment Process
|
||||||
|
|
||||||
### 1. Generate Secrets
|
### 1. Generate Secrets
|
||||||
|
|
@ -81,6 +118,7 @@ Once OAuth is working, remove middleware protection:
|
||||||
### 2. Update CI/CD Vault
|
### 2. Update CI/CD Vault
|
||||||
Add the generated secrets to your Woodpecker CI vault:
|
Add the generated secrets to your Woodpecker CI vault:
|
||||||
- `CLIENT_SECRET_PORTAINER`
|
- `CLIENT_SECRET_PORTAINER`
|
||||||
|
- `CLIENT_SECRET_GITEA`
|
||||||
|
|
||||||
### 3. Deploy Authelia
|
### 3. Deploy Authelia
|
||||||
Push changes to trigger CI/CD deployment with new OAuth clients.
|
Push changes to trigger CI/CD deployment with new OAuth clients.
|
||||||
|
|
@ -5,7 +5,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
|
||||||
## 📚 Available Guides
|
## 📚 Available Guides
|
||||||
|
|
||||||
### 🔧 Setup & Configuration
|
### 🔧 Setup & Configuration
|
||||||
- **[OAuth/OIDC Setup Guide](OAUTH_SETUP.md)** - Complete OAuth integration for Portainer and other services
|
- **[OAuth/OIDC Setup Guide](OAUTH_SETUP.md)** - Complete OAuth integration for Portainer, Gitea, and other services
|
||||||
- **[CI/CD Vault Setup](CI_CD_VAULT_SETUP.md)** - Secret management and Woodpecker CI vault configuration
|
- **[CI/CD Vault Setup](CI_CD_VAULT_SETUP.md)** - Secret management and Woodpecker CI vault configuration
|
||||||
|
|
||||||
### 🚀 Getting Started
|
### 🚀 Getting Started
|
||||||
|
|
@ -18,7 +18,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
|
||||||
2. **OAuth Integration**
|
2. **OAuth Integration**
|
||||||
- Generate OAuth client secrets with `./scripts/generate-oauth-secrets.sh`
|
- Generate OAuth client secrets with `./scripts/generate-oauth-secrets.sh`
|
||||||
- Follow [OAuth Setup Guide](OAUTH_SETUP.md) for service configuration
|
- Follow [OAuth Setup Guide](OAUTH_SETUP.md) for service configuration
|
||||||
- Configure individual services (e.g. Portainer) with OAuth
|
- Configure individual services (Portainer, Gitea) with OAuth
|
||||||
|
|
||||||
3. **Production Deployment**
|
3. **Production Deployment**
|
||||||
- Commit changes to trigger CI/CD pipeline
|
- Commit changes to trigger CI/CD pipeline
|
||||||
|
|
@ -55,7 +55,7 @@ docker compose -f docker-compose.dev.yml up -d
|
||||||
### Required Secrets (12 Total)
|
### Required Secrets (12 Total)
|
||||||
- **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP
|
- **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP
|
||||||
- **OIDC Secrets (3)**: HMAC, private key, JWKS key
|
- **OIDC Secrets (3)**: HMAC, private key, JWKS key
|
||||||
- **Client Secrets (3)**: Headscale (2), Portainer
|
- **Client Secrets (4)**: Headscale (2), Portainer, Gitea
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
|
@ -1,40 +1,452 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Woodpecker production deploy for the Authentik-backed ATLAS stack.
|
|
||||||
|
|
||||||
set -eu
|
################################################################################
|
||||||
|
# WOODPECKER CI PRODUCTION DEPLOYMENT SCRIPT
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# ⚠️ WARNING: THIS SCRIPT IS EXCLUSIVELY FOR WOODPECKER CI USE
|
||||||
|
#
|
||||||
|
# This script is designed to run within the Woodpecker CI environment with
|
||||||
|
# specific environment variables and Docker socket access.
|
||||||
|
#
|
||||||
|
# 🚫 DO NOT RUN THIS ON A DEVELOPER WORKSTATION
|
||||||
|
# 🚫 This will attempt to remove production Docker stacks and secrets
|
||||||
|
# 🚫 This requires access to production Docker swarm manager nodes
|
||||||
|
#
|
||||||
|
# This script handles:
|
||||||
|
# - Production stack removal and cleanup
|
||||||
|
# - Docker secrets recreation with fresh values
|
||||||
|
# - New stack deployment with verification
|
||||||
|
# - Health checking and deployment validation
|
||||||
|
# - Rollback capability on failure
|
||||||
|
# - Concurrent execution prevention
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
|
||||||
STACK_NAME="${STACK_NAME:-atlas}"
|
set -euo pipefail
|
||||||
STACK_FILE="${STACK_FILE:-stack.production.yml}"
|
|
||||||
SS_ATLAS_IMAGE="${SS_ATLAS_IMAGE:-git.nixc.us/a250/ss-atlas:production}"
|
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
MAX_RETRIES=3
|
||||||
|
RETRY_DELAY=5
|
||||||
|
FORCE_PULL=true # Always pull latest images
|
||||||
|
DEPLOYMENT_TIMEOUT=180 # Reduced from 300s to 180s (3 minutes)
|
||||||
|
HEALTH_CHECK_TIMEOUT=90 # Reduced from 120s to 90s
|
||||||
|
|
||||||
|
# Color codes for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
PURPLE='\033[0;35m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Global variables for cleanup
|
||||||
|
DEPLOYMENT_STARTED=false
|
||||||
|
OLD_IMAGE_HASH=""
|
||||||
|
NEW_IMAGE_HASH=""
|
||||||
|
ROLLBACK_NEEDED=false
|
||||||
|
|
||||||
|
# Logging functions
|
||||||
log() {
|
log() {
|
||||||
printf '[ci-deploy] %s\n' "$*"
|
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')] $1${NC}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fail() {
|
error() {
|
||||||
printf '[ci-deploy] ERROR: %s\n' "$*" >&2
|
echo -e "${RED}[ERROR] $1${NC}"
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[ -f "$STACK_FILE" ] || fail "Missing $STACK_FILE"
|
success() {
|
||||||
docker info >/dev/null 2>&1 || fail "Docker daemon is not reachable"
|
echo -e "${GREEN}[SUCCESS] $1${NC}"
|
||||||
[ "$(docker info --format '{{.Swarm.LocalNodeState}}')" = "active" ] || fail "Docker is not an active swarm manager"
|
}
|
||||||
|
|
||||||
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
|
warning() {
|
||||||
log "Logging into git.nixc.us"
|
echo -e "${YELLOW}[WARNING] $1${NC}"
|
||||||
printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.nixc.us
|
}
|
||||||
fi
|
|
||||||
|
|
||||||
log "Pulling $SS_ATLAS_IMAGE"
|
debug() {
|
||||||
docker pull "$SS_ATLAS_IMAGE"
|
echo -e "${PURPLE}[DEBUG] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
log "Deploying $STACK_NAME from $STACK_FILE"
|
# Cleanup function - runs on script exit
|
||||||
docker stack deploy --with-registry-auth -c "$STACK_FILE" "$STACK_NAME"
|
cleanup() {
|
||||||
|
local exit_code=$?
|
||||||
|
debug "Script completed with exit code: $exit_code"
|
||||||
|
exit $exit_code
|
||||||
|
}
|
||||||
|
|
||||||
if docker service inspect "${STACK_NAME}_ss-atlas" >/dev/null 2>&1; then
|
# Set up cleanup trap
|
||||||
log "Forcing ${STACK_NAME}_ss-atlas to $SS_ATLAS_IMAGE"
|
trap cleanup EXIT INT TERM
|
||||||
docker service update --force --image "$SS_ATLAS_IMAGE" "${STACK_NAME}_ss-atlas"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Current stack tasks"
|
# Retry function for operations that might fail transiently
|
||||||
docker stack ps "$STACK_NAME" --no-trunc
|
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 "$@"
|
||||||
|
|
@ -4,7 +4,7 @@ set -e
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
# Config is in stack.yml; do not use .env
|
[ -f .env ] && set -a && . .env && set +a
|
||||||
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
echo "ERROR: Working tree is dirty. Commit your changes before deploying." >&2
|
echo "ERROR: Working tree is dirty. Commit your changes before deploying." >&2
|
||||||
|
|
@ -106,6 +106,11 @@ Add these secrets to your Woodpecker CI vault:
|
||||||
- **Secret File**: `secrets/clients/portainer-secret.txt`
|
- **Secret File**: `secrets/clients/portainer-secret.txt`
|
||||||
- **Value**: (copy content from the file above)
|
- **Value**: (copy content from the file above)
|
||||||
|
|
||||||
|
### Gitea OAuth
|
||||||
|
- **Variable Name**: `CLIENT_SECRET_GITEA`
|
||||||
|
- **Secret File**: `secrets/clients/gitea-secret.txt`
|
||||||
|
- **Value**: (copy content from the file above)
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
1. **Never commit these files** - they are automatically gitignored
|
1. **Never commit these files** - they are automatically gitignored
|
||||||
|
|
@ -119,6 +124,9 @@ If using Woodpecker CLI:
|
||||||
```bash
|
```bash
|
||||||
# Update Portainer secret
|
# Update Portainer secret
|
||||||
woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)"
|
woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)"
|
||||||
|
|
||||||
|
# Update Gitea secret
|
||||||
|
woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --value "$(cat secrets/clients/gitea-secret.txt)"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
@ -141,15 +149,17 @@ print_summary() {
|
||||||
echo "${YELLOW}📁 Generated Files:${NC}"
|
echo "${YELLOW}📁 Generated Files:${NC}"
|
||||||
echo " • secrets/oauth-secrets.env"
|
echo " • secrets/oauth-secrets.env"
|
||||||
echo " • secrets/clients/portainer-secret.txt"
|
echo " • secrets/clients/portainer-secret.txt"
|
||||||
|
echo " • secrets/clients/gitea-secret.txt"
|
||||||
echo " • secrets/VAULT_SECRETS.md"
|
echo " • secrets/VAULT_SECRETS.md"
|
||||||
echo
|
echo
|
||||||
echo "${YELLOW}🔑 Required CI/CD Vault Updates:${NC}"
|
echo "${YELLOW}🔑 Required CI/CD Vault Updates:${NC}"
|
||||||
echo " • CLIENT_SECRET_PORTAINER"
|
echo " • CLIENT_SECRET_PORTAINER"
|
||||||
|
echo " • CLIENT_SECRET_GITEA"
|
||||||
echo
|
echo
|
||||||
echo "${RED}⚠️ NEXT STEPS:${NC}"
|
echo "${RED}⚠️ NEXT STEPS:${NC}"
|
||||||
echo " 1. Update your CI/CD vault with new secrets"
|
echo " 1. Update your CI/CD vault with new secrets"
|
||||||
echo " 2. Deploy Authelia to use new client configurations"
|
echo " 2. Deploy Authelia to use new client configurations"
|
||||||
echo " 3. Configure OAuth in Portainer admin panel"
|
echo " 3. Configure OAuth in Portainer and Gitea admin panels"
|
||||||
echo " 4. Test authentication flows"
|
echo " 4. Test authentication flows"
|
||||||
echo
|
echo
|
||||||
echo "${BLUE}📖 Full setup guide: docs/OAUTH_SETUP.md${NC}"
|
echo "${BLUE}📖 Full setup guide: docs/OAUTH_SETUP.md${NC}"
|
||||||
|
|
@ -185,6 +195,7 @@ main() {
|
||||||
|
|
||||||
# Generate client secrets
|
# Generate client secrets
|
||||||
generate_client_secret "portainer" "portainer-secret.txt"
|
generate_client_secret "portainer" "portainer-secret.txt"
|
||||||
|
generate_client_secret "gitea" "gitea-secret.txt"
|
||||||
|
|
||||||
create_vault_instructions
|
create_vault_instructions
|
||||||
print_summary
|
print_summary
|
||||||
150
ship.sh
150
ship.sh
|
|
@ -1,150 +0,0 @@
|
||||||
#!/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,190 +1,209 @@
|
||||||
services:
|
x-authelia-env: &authelia-env
|
||||||
atlas-postgres:
|
X_AUTHELIA_EMAIL: authelia@a250.ca
|
||||||
image: postgres:16-alpine
|
X_AUTHELIA_SITE_NAME: ATLAS
|
||||||
environment:
|
X_AUTHELIA_CONFIG_FILTERS: template
|
||||||
POSTGRES_DB: atlas
|
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
|
||||||
POSTGRES_USER: atlas
|
TRAEFIK_DOMAIN: bc.a250.ca
|
||||||
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
|
|
||||||
|
|
||||||
authentik-postgres:
|
secrets:
|
||||||
image: postgres:16-alpine
|
AUTHENTICATION_BACKEND_LDAP_PASSWORD:
|
||||||
environment:
|
external: true
|
||||||
POSTGRES_DB: authentik
|
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET:
|
||||||
POSTGRES_USER: authentik
|
external: true
|
||||||
POSTGRES_PASSWORD: authentik
|
# TEMPORARILY DISABLED - OIDC provider disabled
|
||||||
volumes:
|
# IDENTITY_PROVIDERS_OIDC_HMAC_SECRET:
|
||||||
- authentik_postgres_data:/var/lib/postgresql/data
|
# external: true
|
||||||
networks:
|
# IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY:
|
||||||
- atlas_internal
|
# external: true
|
||||||
healthcheck:
|
# IDENTITY_PROVIDERS_OIDC_JWKS_KEY:
|
||||||
test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
|
# external: true
|
||||||
start_period: 10s
|
NOTIFIER_SMTP_PASSWORD:
|
||||||
interval: 30s
|
external: true
|
||||||
timeout: 5s
|
SESSION_SECRET:
|
||||||
retries: 5
|
external: true
|
||||||
|
STORAGE_ENCRYPTION_KEY:
|
||||||
authentik-redis:
|
external: true
|
||||||
image: redis:7-alpine
|
# TEMPORARILY DISABLED - OAuth clients disabled
|
||||||
command: redis-server --save 60 1 --loglevel warning
|
# CLIENT_SECRET_HEADSCALE:
|
||||||
volumes:
|
# external: true
|
||||||
- authentik_redis_data:/data
|
# CLIENT_SECRET_HEADADMIN:
|
||||||
networks:
|
# external: true
|
||||||
- atlas_internal
|
# CLIENT_SECRET_PORTAINER:
|
||||||
|
# external: true
|
||||||
authentik-server:
|
# TEMPORARILY DISABLED - Gitea OAuth (not ready yet)
|
||||||
image: ghcr.io/goauthentik/server:latest
|
# CLIENT_SECRET_GITEA:
|
||||||
command: server
|
# external: true
|
||||||
environment:
|
|
||||||
AUTHENTIK_SECRET_KEY: change-me-before-production
|
|
||||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
|
||||||
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
|
|
||||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
|
||||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
|
||||||
AUTHENTIK_POSTGRESQL__PASSWORD: authentik
|
|
||||||
AUTHENTIK_BOOTSTRAP_PASSWORD: change-me-before-production
|
|
||||||
AUTHENTIK_BOOTSTRAP_TOKEN: change-me-before-production
|
|
||||||
AUTHENTIK_BOOTSTRAP_EMAIL: admin@a250.ca
|
|
||||||
volumes:
|
|
||||||
- authentik_media:/media
|
|
||||||
- authentik_templates:/templates
|
|
||||||
networks:
|
|
||||||
- atlas_internal
|
|
||||||
- traefik
|
|
||||||
deploy:
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.docker.network=traefik"
|
|
||||||
- "traefik.http.middlewares.authentik.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
|
|
||||||
- "traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true"
|
|
||||||
- "traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid"
|
|
||||||
- "traefik.http.routers.authentik.rule=Host(`app.a250.ca`) && (PathPrefix(`/outpost.goauthentik.io/`) || PathPrefix(`/if/`) || PathPrefix(`/flows/`) || PathPrefix(`/application/`) || PathPrefix(`/source/`) || PathPrefix(`/api/`) || PathPrefix(`/static/`) || PathPrefix(`/media/`) || PathPrefix(`/ws/`))"
|
|
||||||
- "traefik.http.routers.authentik.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.authentik.tls=true"
|
|
||||||
- "traefik.http.routers.authentik.tls.certresolver=letsencryptresolver"
|
|
||||||
- "traefik.http.routers.authentik.priority=100"
|
|
||||||
- "traefik.http.routers.authentik-oauth.rule=Host(`app.a250.ca`) && (PathPrefix(`/flows/`) || PathPrefix(`/application/`) || PathPrefix(`/source/`) || PathPrefix(`/api/`) || PathPrefix(`/static/`) || PathPrefix(`/media/`) || PathPrefix(`/ws/`))"
|
|
||||||
- "traefik.http.routers.authentik-oauth.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.authentik-oauth.tls=true"
|
|
||||||
- "traefik.http.routers.authentik-oauth.tls.certresolver=letsencryptresolver"
|
|
||||||
- "traefik.http.routers.authentik-oauth.priority=200"
|
|
||||||
- "traefik.http.routers.authentik-oauth.service=authentik"
|
|
||||||
- "traefik.http.services.authentik.loadbalancer.server.port=9000"
|
|
||||||
|
|
||||||
authentik-worker:
|
|
||||||
image: ghcr.io/goauthentik/server:latest
|
|
||||||
command: worker
|
|
||||||
environment:
|
|
||||||
AUTHENTIK_SECRET_KEY: change-me-before-production
|
|
||||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
|
||||||
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
|
|
||||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
|
||||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
|
||||||
AUTHENTIK_POSTGRESQL__PASSWORD: authentik
|
|
||||||
user: root
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- authentik_media:/media
|
|
||||||
- authentik_templates:/templates
|
|
||||||
networks:
|
|
||||||
- atlas_internal
|
|
||||||
|
|
||||||
ss-atlas:
|
|
||||||
image: git.nixc.us/a250/ss-atlas:production
|
|
||||||
environment:
|
|
||||||
- STRIPE_SECRET_KEY=sk_test_51T6uRBRfasa3uSsu1EwvRHaGKhWopjeBz15aDACaI3ectJ1przHIKTX2DAqJu7DDtsBMhIuRiyVf0MY9ivtUvzk800kEZ5advL
|
|
||||||
- STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
|
||||||
- STRIPE_PRICE_ID=price_1T6v8dRfasa3uSsuCWmIC0Fn
|
|
||||||
- STRIPE_PRICE_ID_FREE=price_1T7NOURfasa3uSsuEpbKAD1h
|
|
||||||
- STRIPE_PRICE_ID_YEAR=price_1T7NOURfasa3uSsu3fB9ivyn
|
|
||||||
- STRIPE_PRICE_ID_MONTH_100=price_1T7NOVRfasa3uSsuEaxzMNno
|
|
||||||
- STRIPE_PRICE_ID_MONTH_200=price_1T7NOVRfasa3uSsucQRRlPCi
|
|
||||||
- STRIPE_PAYMENT_LINK=
|
|
||||||
- FREE_TIER_LIMIT=10
|
|
||||||
- YEAR_TIER_LIMIT=50
|
|
||||||
- MAX_SIGNUPS=0
|
|
||||||
- DOCKER_HOST=unix:///var/run/docker.sock
|
|
||||||
- APP_URL=https://app.a250.ca
|
|
||||||
- IDENTITY_URL=https://app.a250.ca/if/user/
|
|
||||||
- DATABASE_URL=postgres://atlas:atlas@atlas-postgres:5432/atlas?sslmode=disable
|
|
||||||
- TRAEFIK_DOMAIN=app.a250.ca
|
|
||||||
- TRAEFIK_NETWORK=traefik
|
|
||||||
- TRAEFIK_DOCKER_NETWORK=traefik
|
|
||||||
- CUSTOMER_DOMAIN=app.a250.ca
|
|
||||||
- TEMPLATE_PATH=/app/templates
|
|
||||||
- ARCHIVE_PATH=/archives
|
|
||||||
- LANDING_TAGLINE=Your own workspace, ready in minutes.
|
|
||||||
- LANDING_FEATURES=Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime
|
|
||||||
- ADMIN_SECRET=
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- atlas_archives:/archives
|
|
||||||
networks:
|
|
||||||
- atlas_internal
|
|
||||||
- traefik
|
|
||||||
deploy:
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.docker.network=traefik"
|
|
||||||
- "traefik.http.routers.ss-atlas.rule=Host(`app.a250.ca`)"
|
|
||||||
- "traefik.http.routers.ss-atlas.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.ss-atlas.tls=true"
|
|
||||||
- "traefik.http.routers.ss-atlas.tls.certresolver=letsencryptresolver"
|
|
||||||
- "traefik.http.routers.ss-atlas.priority=1"
|
|
||||||
- "traefik.http.routers.ss-atlas.service=ss-atlas"
|
|
||||||
- "traefik.http.routers.ss-atlas-protected.rule=Host(`app.a250.ca`) && (PathPrefix(`/checkout`) || PathPrefix(`/subscribe`) || PathPrefix(`/activate`) || PathPrefix(`/dashboard`) || PathPrefix(`/link-stripe-customer`) || PathPrefix(`/portal`) || PathPrefix(`/resubscribe`) || PathPrefix(`/stack-manage`))"
|
|
||||||
- "traefik.http.routers.ss-atlas-protected.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.ss-atlas-protected.tls=true"
|
|
||||||
- "traefik.http.routers.ss-atlas-protected.tls.certresolver=letsencryptresolver"
|
|
||||||
- "traefik.http.routers.ss-atlas-protected.priority=20"
|
|
||||||
- "traefik.http.routers.ss-atlas-protected.middlewares=authentik@swarm"
|
|
||||||
- "traefik.http.routers.ss-atlas-protected.service=ss-atlas"
|
|
||||||
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.rule=Host(`app.a250.ca`) && PathPrefix(`/i/`)"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.tls=true"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.tls.certresolver=letsencryptresolver"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.priority=15"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.middlewares=authentik@swarm"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.service=ss-atlas"
|
|
||||||
|
|
||||||
whoami:
|
|
||||||
image: traefik/whoami
|
|
||||||
networks:
|
|
||||||
- atlas_internal
|
|
||||||
- traefik
|
|
||||||
deploy:
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.docker.network=traefik"
|
|
||||||
- "traefik.http.routers.whoami.rule=Host(`app.a250.ca`) && PathPrefix(`/whoami`)"
|
|
||||||
- "traefik.http.routers.whoami.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.whoami.tls=true"
|
|
||||||
- "traefik.http.routers.whoami.tls.certresolver=letsencryptresolver"
|
|
||||||
- "traefik.http.routers.whoami.middlewares=strip-whoami@swarm,authentik@swarm"
|
|
||||||
- "traefik.http.middlewares.strip-whoami.stripprefix.prefixes=/whoami"
|
|
||||||
- "traefik.http.services.whoami.loadbalancer.server.port=80"
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
atlas_internal:
|
default:
|
||||||
driver: overlay
|
driver: overlay
|
||||||
attachable: true
|
|
||||||
traefik:
|
traefik:
|
||||||
external: true
|
external: true
|
||||||
|
ad:
|
||||||
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
atlas_archives:
|
authelia_config:
|
||||||
atlas_postgres_data:
|
driver: local
|
||||||
authentik_postgres_data:
|
authelia_assets:
|
||||||
authentik_redis_data:
|
driver: local
|
||||||
authentik_media:
|
authelia_redis_data:
|
||||||
authentik_templates:
|
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"
|
||||||
218
stack.yml
218
stack.yml
|
|
@ -1,30 +1,164 @@
|
||||||
services:
|
services:
|
||||||
atlas-postgres:
|
mariadb:
|
||||||
image: postgres:16-alpine
|
image: mariadb:latest
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: atlas
|
MYSQL_ROOT_PASSWORD: dev_authelia_root
|
||||||
POSTGRES_USER: atlas
|
MYSQL_DATABASE: authelia
|
||||||
POSTGRES_PASSWORD: atlas
|
MYSQL_USER: authelia
|
||||||
|
MYSQL_PASSWORD: authelia
|
||||||
volumes:
|
volumes:
|
||||||
- atlas_postgres_data:/var/lib/postgresql/data
|
- mariadb_data:/var/lib/mysql
|
||||||
networks:
|
networks:
|
||||||
- atlas_internal
|
- authelia_dev
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U atlas -d atlas"]
|
test: [ "CMD", "/usr/local/bin/healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized" ]
|
||||||
|
start_period: 30s
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
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
|
start_period: 10s
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 3
|
||||||
|
|
||||||
|
lldap:
|
||||||
|
image: nitnelave/lldap:latest
|
||||||
|
volumes:
|
||||||
|
- lldap_data:/data
|
||||||
|
environment:
|
||||||
|
- LLDAP_JWT_SECRET=I2sNvGvhzZlTJWPfNL9MBPFGhyG/gWU5wHz6wFsIC3I=
|
||||||
|
- LLDAP_LDAP_USER_PASS=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
|
||||||
|
- LLDAP_LDAP_BASE_DN=dc=a250,dc=ca
|
||||||
|
- PUID=33
|
||||||
|
- PGID=33
|
||||||
|
networks:
|
||||||
|
- authelia_dev
|
||||||
|
deploy:
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.lldap.rule=Host(`bc.a250.ca`) && PathPrefix(`/admin/lldap`)"
|
||||||
|
- "traefik.http.routers.lldap.middlewares=strip-lldap@swarm"
|
||||||
|
- "traefik.http.middlewares.strip-lldap.stripprefix.prefixes=/admin/lldap"
|
||||||
|
- "traefik.http.routers.lldap.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.lldap.tls=true"
|
||||||
|
- "traefik.http.services.lldap.loadbalancer.server.port=17170"
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "curl", "-f", "http://localhost:17170/health" ]
|
||||||
|
start_period: 10s
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
authelia:
|
||||||
|
image: git.nixc.us/a250/authelia:dev-authelia
|
||||||
|
user: root
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
mkdir -p /run/secrets
|
||||||
|
echo "$${IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET}" > /run/secrets/IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
|
||||||
|
echo "$${STORAGE_ENCRYPTION_KEY}" > /run/secrets/STORAGE_ENCRYPTION_KEY
|
||||||
|
echo "$${SESSION_SECRET}" > /run/secrets/SESSION_SECRET
|
||||||
|
echo "$${NOTIFIER_SMTP_PASSWORD}" > /run/secrets/NOTIFIER_SMTP_PASSWORD
|
||||||
|
echo "$${AUTHENTICATION_BACKEND_LDAP_PASSWORD}" > /run/secrets/AUTHENTICATION_BACKEND_LDAP_PASSWORD
|
||||||
|
echo "$${IDENTITY_PROVIDERS_OIDC_HMAC_SECRET}" > /run/secrets/IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
|
||||||
|
echo "$${IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY}" > /run/secrets/IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
|
||||||
|
echo "$${IDENTITY_PROVIDERS_OIDC_JWKS_KEY}" > /run/secrets/IDENTITY_PROVIDERS_OIDC_JWKS_KEY
|
||||||
|
echo "$${CLIENT_SECRET_HEADSCALE}" > /run/secrets/CLIENT_SECRET_HEADSCALE
|
||||||
|
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
|
||||||
|
echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
|
||||||
|
echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
|
||||||
|
{ echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/login(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe/?$$'"; echo " - '^/success(/|\\?.*)?$$'"; echo " - '^/webhook/stripe/?$$'"; echo " - '^/resend-reset/?$$'"; echo " - '^/health/?$$'"; echo " - '^/version/?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/activate/?$$'"; echo " - '^/dashboard/?$$'"; echo ' - domain: bc.a250.ca'; echo ' subject:'; echo ' - group:customers'; echo ' policy: two_factor'; echo ' resources:'; echo " - '^/portal/?$$'"; echo " - '^/resubscribe/?$$'"; echo " - '^/stack-manage/?$$'"; echo " - '^/i/[a-z0-9-]+(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/admin/lldap(/.*)?$$'"; echo " - '^/whoami(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: deny'; } > /config/configuration.acl.yml
|
||||||
|
exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml --config=/config/configuration.notifier.yml --config=/config/configuration.identity.providers.yml --config=/config/configuration.oidc.clients.yml
|
||||||
|
environment:
|
||||||
|
AUTHELIA_SERVER_ADDRESS: tcp://0.0.0.0:9091/login
|
||||||
|
X_AUTHELIA_EMAIL: authelia@a250.ca
|
||||||
|
X_AUTHELIA_SITE_NAME: a250.ca
|
||||||
|
X_AUTHELIA_CONFIG_FILTERS: template
|
||||||
|
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
|
||||||
|
TRAEFIK_DOMAIN: bc.a250.ca
|
||||||
|
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
|
||||||
|
STORAGE_ENCRYPTION_KEY: DvbtMjsNDIC3eqtNaPtdHm/f07dtlHREgieDStTu9NA=
|
||||||
|
SESSION_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
|
||||||
|
NOTIFIER_SMTP_PASSWORD: 8P7ah6U5ZjbQ2Faaw1fJoehxJrMOslCu
|
||||||
|
AUTHENTICATION_BACKEND_LDAP_PASSWORD: /ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
|
||||||
|
IDENTITY_PROVIDERS_OIDC_HMAC_SECRET: Pq5+dkrmh04daeSEPEXGq6JniiPsgJ6nHBi/ettUGLSKcuZtnaw3em8/BCXn2iFhUqTRdLSeCiWMbo+oEl/ZYA==
|
||||||
|
IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY: |
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC0JC4jaDhdqk3U
|
||||||
|
0yDwAh5JVQR84htkPY0Trf5VQYNnBhglo2CqRm6jwjzfOJLBruCUokbG5wJL+OU8
|
||||||
|
zDm3aQAhF0xWPEr1ad1U+fIezdF4pZ0fDHVAG9MYTwZYD8iYQclVhoKA8M6/gT15
|
||||||
|
QHq0Fzfgf4U5dmsNH2CWiFi+TAWQ85bxLiXchTnRkoyZ445xBqCuthJyvvUtrZrl
|
||||||
|
dCAcnNJ6kdGypXwqAuOGrRDz1g9cv52aoJC0k747EnMcmm1HEuR2zGXyw2RM+Sbu
|
||||||
|
GrUhLk2vCE448zKXuJGEckalMn2yBfaf5RsZYC9j7SwB0ehyNk5Bn4tKuPt38C7T
|
||||||
|
wWkIoI/DAgMBAAECggEAAIQB/2cmK8GrC14dwAVUu0NoPRTgnMulHCNPxERPV5Va
|
||||||
|
4fCy/CNlE0iHdODsLdKN7gVkGOAPnGwP+LnIIh0Sbp9q2bkk3C/IMTZ6wCY5E64i
|
||||||
|
e85E7HQOVjytRfjb/on7RSianKF6PG4Z4PKTgPFE30c+K5XwZIJse/UHKM3kgWLp
|
||||||
|
exKVvYyKDrERunDJqZbYsxSnixk8TavOWFHkpk0wHYvxso6a7jQfEjDWh3N7lduj
|
||||||
|
RlaesSO+NJrZDq44zbyJNsFjh4DsNITdBwYXERPUS33Dp+IlrD2SeQMtMBtz+7Ha
|
||||||
|
Pd8jMpx8Fw/S3CnjSYRRzDj5Z21EfspfoO6v1ULA0QKBgQDyQejBS7QNwNRIcnhO
|
||||||
|
b6TVOPmqcOL9gR/mkC4VmWFvf4pTA69OOuU/gHeF6+J40Z4tuFggHMoPmZuPi9AL
|
||||||
|
GSp2UZQHYa7BxTk7XxESflF/8HzgbtFtK/0dUp1l2JN26qha+djQADFFPNWs8abX
|
||||||
|
wpbKfjPqLzwR8K5kCtbd3WWDrwKBgQC+XDajJ6I4k9hwfYDxb35UkNFjboK4NfTY
|
||||||
|
u5Eiz1NhbqqkNV8idZhadJfnbgIAymqr9Yf9M9ncAbuUhCDI2r/VL1CLMx/y/DGH
|
||||||
|
RxlXWq4sArG1xpR1Muc9W8tTT9cf9XDMmuL81wYccXGqv3RpYQM/VtYIRSWvC0HE
|
||||||
|
FxZCGPa2LQKBgHlg1IGksH4Dk1kJIYYLIgdDGLRxAwoI3DblHnHr+4ml2WRmgDst
|
||||||
|
/xamAzyyRzJJtHsr1duhEQxn5i0x2/bzkPbfQM/B/ZFQg7BfnWoqqCL2F1tLqtqM
|
||||||
|
I7HBZuNUc+4s/FU4wYzVy9no9RZFrVaFRJAIU3KOYAaNFJNDawyWlPo5AoGARe6C
|
||||||
|
c/W/dqF5xfmVQR0Af/ijs6+Jfjr0NBrT+sHHk+ef8Ktaw8IHslNa6r5TJg82mO2e
|
||||||
|
g7pksppAWxMfKCqUhrDXGgwyFIXpfBT2jkzV530l4+2L5HJK2RO74mNWWHtGcSQF
|
||||||
|
d3VW3WQfqeaj0YK+Oqqf/nHIokG0a2E/4BBjshECgYAnlU2Fl7uI1lQBbWsckaQ9
|
||||||
|
EVeSDtrRvNuER0Eh3WFni2affOqB9qAZXNfCZ/goFJoNgk4fww0OqmewX9Y18/3a
|
||||||
|
vsrm7L7OKFFlM6vmIG1nPX/s5l++mkMe+qRd4B7C4NSF0bzJlweTozQFDp+prp1y
|
||||||
|
SHERk3EUdAZn7yyIISd/Qg==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
IDENTITY_PROVIDERS_OIDC_JWKS_KEY: mbfKKlpQ5QEzrmBCCcOg7yubDBKZtKCAiL7rGtVdMq/hpCorO+Qiei2fKbB/xieDS3BIg5BMza5fZm5w0hMiNA==
|
||||||
|
CLIENT_SECRET_HEADSCALE: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
|
||||||
|
CLIENT_SECRET_HEADADMIN: RAxwkJxwMBSYkaA0r+D5qZdEFIrVEZJbigOPtkCBED8=
|
||||||
|
CLIENT_SECRET_PORTAINER: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
|
||||||
|
CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
|
||||||
|
volumes:
|
||||||
|
- authelia_data:/data
|
||||||
|
networks:
|
||||||
|
- authelia_dev
|
||||||
|
deploy:
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.authelia.rule=Host(`bc.a250.ca`) && PathPrefix(`/login`)"
|
||||||
|
- "traefik.http.routers.authelia.priority=10"
|
||||||
|
- "traefik.http.routers.authelia.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.authelia.tls=true"
|
||||||
|
- "traefik.http.services.authelia.loadbalancer.server.port=9091"
|
||||||
|
- "traefik.http.middlewares.authelia-auth.forwardauth.address=http://authelia:9091/login/api/authz/forward-auth?rd=https://bc.a250.ca/login/"
|
||||||
|
- "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true"
|
||||||
|
- "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/login/api/health || exit 1" ]
|
||||||
|
start_period: 15s
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.6
|
image: traefik:v3.1
|
||||||
command:
|
command:
|
||||||
- "--api.insecure=true"
|
- "--api.insecure=true"
|
||||||
- "--providers.swarm=true"
|
- "--providers.swarm=true"
|
||||||
- "--providers.swarm.endpoint=unix:///var/run/docker.sock"
|
- "--providers.swarm.endpoint=unix:///var/run/docker.sock"
|
||||||
- "--providers.swarm.watch=true"
|
- "--providers.swarm.watch=true"
|
||||||
- "--providers.swarm.exposedbydefault=false"
|
- "--providers.swarm.exposedbydefault=false"
|
||||||
- "--providers.swarm.network=atlas_atlas_internal"
|
- "--providers.swarm.network=atlas_authelia_dev"
|
||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
||||||
|
|
@ -36,7 +170,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||||
networks:
|
networks:
|
||||||
- atlas_internal
|
- authelia_dev
|
||||||
deploy:
|
deploy:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
|
@ -48,40 +182,41 @@ services:
|
||||||
- "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik"
|
- "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik"
|
||||||
- "traefik.http.services.traefik-api.loadbalancer.server.port=8080"
|
- "traefik.http.services.traefik-api.loadbalancer.server.port=8080"
|
||||||
|
|
||||||
# SUBSCRIBE/STRIPE: Do not remove or reorder. Values are in this file; do not use .env.
|
|
||||||
# See .cursor/rules/protect-subscribe-settings.mdc
|
|
||||||
ss-atlas:
|
ss-atlas:
|
||||||
image: atlas-ss-atlas:latest
|
image: atlas-ss-atlas:latest
|
||||||
environment:
|
environment:
|
||||||
- STRIPE_SECRET_KEY=sk_test_51T6uRBRfasa3uSsu1EwvRHaGKhWopjeBz15aDACaI3ectJ1przHIKTX2DAqJu7DDtsBMhIuRiyVf0MY9ivtUvzk800kEZ5advL
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder}
|
||||||
- STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-whsec_placeholder}
|
||||||
- STRIPE_PRICE_ID=price_1T6v8dRfasa3uSsuCWmIC0Fn
|
- STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-}
|
||||||
- STRIPE_PRICE_ID_FREE=price_1T7NOURfasa3uSsuEpbKAD1h
|
- STRIPE_PRICE_ID_FREE=${STRIPE_PRICE_ID_FREE:-}
|
||||||
- STRIPE_PRICE_ID_YEAR=price_1T7NOURfasa3uSsu3fB9ivyn
|
- STRIPE_PRICE_ID_YEAR=${STRIPE_PRICE_ID_YEAR:-}
|
||||||
- STRIPE_PRICE_ID_MONTH_100=price_1T7NOVRfasa3uSsuEaxzMNno
|
- STRIPE_PRICE_ID_MONTH_100=${STRIPE_PRICE_ID_MONTH_100:-}
|
||||||
- STRIPE_PRICE_ID_MONTH_200=price_1T7NOVRfasa3uSsucQRRlPCi
|
- STRIPE_PRICE_ID_MONTH_200=${STRIPE_PRICE_ID_MONTH_200:-}
|
||||||
- STRIPE_PAYMENT_LINK=
|
- STRIPE_PAYMENT_LINK=${STRIPE_PAYMENT_LINK:-}
|
||||||
- FREE_TIER_LIMIT=10
|
- FREE_TIER_LIMIT=${FREE_TIER_LIMIT:-10}
|
||||||
- YEAR_TIER_LIMIT=50
|
- YEAR_TIER_LIMIT=${YEAR_TIER_LIMIT:-50}
|
||||||
- MAX_SIGNUPS=0
|
- 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
|
||||||
- DOCKER_HOST=unix:///var/run/docker.sock
|
- DOCKER_HOST=unix:///var/run/docker.sock
|
||||||
- APP_URL=https://bc.a250.ca
|
- APP_URL=https://bc.a250.ca
|
||||||
- IDENTITY_URL=https://bc.a250.ca/login
|
- AUTHELIA_URL=https://bc.a250.ca/login
|
||||||
- DATABASE_URL=postgres://atlas:atlas@atlas-postgres:5432/atlas?sslmode=disable
|
- AUTHELIA_INTERNAL_URL=http://authelia:9091/login
|
||||||
- TRAEFIK_DOMAIN=bc.a250.ca
|
- TRAEFIK_DOMAIN=bc.a250.ca
|
||||||
- TRAEFIK_NETWORK=atlas_internal
|
- TRAEFIK_NETWORK=authelia_dev
|
||||||
- TRAEFIK_DOCKER_NETWORK=atlas_atlas_internal
|
|
||||||
- CUSTOMER_DOMAIN=bc.a250.ca
|
- CUSTOMER_DOMAIN=bc.a250.ca
|
||||||
- TEMPLATE_PATH=/app/templates
|
- TEMPLATE_PATH=/app/templates
|
||||||
- ARCHIVE_PATH=/archives
|
- ARCHIVE_PATH=/archives
|
||||||
- LANDING_TAGLINE=Your own workspace, ready in minutes.
|
- LANDING_TAGLINE=${LANDING_TAGLINE:-Your own workspace, ready in minutes.}
|
||||||
- LANDING_FEATURES=Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime
|
- LANDING_FEATURES=${LANDING_FEATURES:-Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime}
|
||||||
- ADMIN_SECRET=
|
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- atlas_archives:/archives
|
- atlas_archives:/archives
|
||||||
networks:
|
networks:
|
||||||
- atlas_internal
|
- authelia_dev
|
||||||
deploy:
|
deploy:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
|
@ -89,32 +224,31 @@ services:
|
||||||
- "traefik.http.routers.ss-atlas.entrypoints=websecure"
|
- "traefik.http.routers.ss-atlas.entrypoints=websecure"
|
||||||
- "traefik.http.routers.ss-atlas.tls=true"
|
- "traefik.http.routers.ss-atlas.tls=true"
|
||||||
- "traefik.http.routers.ss-atlas.priority=1"
|
- "traefik.http.routers.ss-atlas.priority=1"
|
||||||
|
- "traefik.http.routers.ss-atlas.middlewares=authelia-auth@swarm"
|
||||||
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
|
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
|
||||||
- "traefik.http.routers.ss-atlas-instance.rule=Host(`bc.a250.ca`) && PathPrefix(`/i/`)"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.tls=true"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.priority=15"
|
|
||||||
- "traefik.http.routers.ss-atlas-instance.service=ss-atlas"
|
|
||||||
|
|
||||||
whoami:
|
whoami:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
networks:
|
networks:
|
||||||
- atlas_internal
|
- authelia_dev
|
||||||
deploy:
|
deploy:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.whoami.rule=Host(`bc.a250.ca`) && PathPrefix(`/whoami`)"
|
- "traefik.http.routers.whoami.rule=Host(`bc.a250.ca`) && PathPrefix(`/whoami`)"
|
||||||
- "traefik.http.routers.whoami.entrypoints=websecure"
|
- "traefik.http.routers.whoami.entrypoints=websecure"
|
||||||
- "traefik.http.routers.whoami.tls=true"
|
- "traefik.http.routers.whoami.tls=true"
|
||||||
- "traefik.http.routers.whoami.middlewares=strip-whoami@swarm"
|
- "traefik.http.routers.whoami.middlewares=strip-whoami@swarm,authelia-auth@swarm"
|
||||||
- "traefik.http.middlewares.strip-whoami.stripprefix.prefixes=/whoami"
|
- "traefik.http.middlewares.strip-whoami.stripprefix.prefixes=/whoami"
|
||||||
- "traefik.http.services.whoami.loadbalancer.server.port=80"
|
- "traefik.http.services.whoami.loadbalancer.server.port=80"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
atlas_internal:
|
authelia_dev:
|
||||||
driver: overlay
|
driver: overlay
|
||||||
attachable: true
|
attachable: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
mariadb_data:
|
||||||
|
redis_data:
|
||||||
|
authelia_data:
|
||||||
|
lldap_data:
|
||||||
atlas_archives:
|
atlas_archives:
|
||||||
atlas_postgres_data:
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue