Compare commits
	
		
			7 Commits
		
	
	
		
			12f35934a9
			...
			3918a74963
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3918a74963 | |||
| 84b718e625 | |||
| a768d95ed3 | |||
| b65b7bcd0a | |||
| 99e3af3f30 | |||
| d5e341b9e8 | |||
| 6c68b5339e | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -18,3 +18,4 @@ __pycache__/
 | 
				
			|||||||
/rpm/RPMS
 | 
					/rpm/RPMS
 | 
				
			||||||
/rpm/BUILD
 | 
					/rpm/BUILD
 | 
				
			||||||
/rpm/__VERSION__
 | 
					/rpm/__VERSION__
 | 
				
			||||||
 | 
					docs-sphinx-rst/build/
 | 
				
			||||||
							
								
								
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					3.13
 | 
				
			||||||
							
								
								
									
										443
									
								
								AUTHENTICATION.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										443
									
								
								AUTHENTICATION.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,443 @@
 | 
				
			|||||||
 | 
					# TISBackup Authentication System
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					TISBackup provides a pluggable authentication system for securing the Flask web interface. You can choose between multiple authentication methods based on your security requirements.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Supported Authentication Methods
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **None** - No authentication (default, NOT recommended for production)
 | 
				
			||||||
 | 
					2. **Basic Auth** - HTTP Basic Authentication with username/password
 | 
				
			||||||
 | 
					3. **Flask-Login** - Session-based authentication with username/password
 | 
				
			||||||
 | 
					4. **OAuth2** - OAuth authentication (Google, GitHub, GitLab, or generic provider)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Quick Start
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1. Choose Authentication Method
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Add an `[authentication]` section to `/etc/tis/tisbackup_gui.ini`:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = basic
 | 
				
			||||||
 | 
					username = admin
 | 
				
			||||||
 | 
					password = $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
 | 
				
			||||||
 | 
					use_bcrypt = True
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2. Install Dependencies
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# For Basic Auth
 | 
				
			||||||
 | 
					uv sync --extra auth-basic
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# For Flask-Login
 | 
				
			||||||
 | 
					uv sync --extra auth-login
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# For OAuth
 | 
				
			||||||
 | 
					uv sync --extra auth-oauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# For all auth methods
 | 
				
			||||||
 | 
					uv sync --extra auth-all
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 3. Restart TISBackup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					docker compose restart tisbackup_gui
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Configuration Guide
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Basic Authentication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Simple HTTP Basic Auth with username and password.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Pros:**
 | 
				
			||||||
 | 
					- Easy to set up
 | 
				
			||||||
 | 
					- Works with all HTTP clients
 | 
				
			||||||
 | 
					- No session management needed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Cons:**
 | 
				
			||||||
 | 
					- Credentials sent with every request
 | 
				
			||||||
 | 
					- No logout functionality
 | 
				
			||||||
 | 
					- Browser password prompt can be confusing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Configuration:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = basic
 | 
				
			||||||
 | 
					username = admin
 | 
				
			||||||
 | 
					# Use bcrypt hash (recommended)
 | 
				
			||||||
 | 
					password = $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
 | 
				
			||||||
 | 
					use_bcrypt = True
 | 
				
			||||||
 | 
					realm = TISBackup Admin
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Generate bcrypt hash:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Docker environment:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```yaml
 | 
				
			||||||
 | 
					services:
 | 
				
			||||||
 | 
					  tisbackup_gui:
 | 
				
			||||||
 | 
					    environment:
 | 
				
			||||||
 | 
					      - TISBACKUP_SECRET_KEY=your-secret-key
 | 
				
			||||||
 | 
					    # Optional: Pass credentials via env vars
 | 
				
			||||||
 | 
					    # Then reference in config with ${AUTH_PASSWORD}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Flask-Login Authentication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Session-based authentication with login page and user management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Pros:**
 | 
				
			||||||
 | 
					- Clean login/logout workflow
 | 
				
			||||||
 | 
					- Session-based (no credentials in each request)
 | 
				
			||||||
 | 
					- Multiple users supported
 | 
				
			||||||
 | 
					- Password hashing with bcrypt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Cons:**
 | 
				
			||||||
 | 
					- Requires custom login page
 | 
				
			||||||
 | 
					- Session management overhead
 | 
				
			||||||
 | 
					- Cookies must be enabled
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Configuration:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = flask-login
 | 
				
			||||||
 | 
					users_file = /etc/tis/users.txt
 | 
				
			||||||
 | 
					use_bcrypt = True
 | 
				
			||||||
 | 
					login_view = login
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Create users file** (`/etc/tis/users.txt`):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
 | 
				
			||||||
 | 
					operator:$2b$12$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNO
 | 
				
			||||||
 | 
					viewer:$2b$12$ANOTHERBCRYPTHASHHERE1234567890ABCDEFGHIJKLMNOPQRS
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Generate user entry:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					USERNAME="admin"
 | 
				
			||||||
 | 
					PASSWORD="yourpassword"
 | 
				
			||||||
 | 
					HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'$PASSWORD', bcrypt.gensalt()).decode())")
 | 
				
			||||||
 | 
					echo "$USERNAME:$HASH" >> /etc/tis/users.txt
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Permissions:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					chmod 600 /etc/tis/users.txt
 | 
				
			||||||
 | 
					chown root:root /etc/tis/users.txt
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### OAuth2 Authentication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Delegate authentication to external OAuth providers (Google, GitHub, GitLab, etc.)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Pros:**
 | 
				
			||||||
 | 
					- No password management
 | 
				
			||||||
 | 
					- Leverage existing identity providers
 | 
				
			||||||
 | 
					- Support for SSO
 | 
				
			||||||
 | 
					- Can restrict by domain or specific users
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Cons:**
 | 
				
			||||||
 | 
					- Requires OAuth app registration
 | 
				
			||||||
 | 
					- Internet connectivity required
 | 
				
			||||||
 | 
					- More complex setup
 | 
				
			||||||
 | 
					- External dependency
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Google OAuth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Setup:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
 | 
				
			||||||
 | 
					2. Create OAuth 2.0 Client ID
 | 
				
			||||||
 | 
					3. Add authorized redirect URI: `http://your-server:8080/oauth/callback`
 | 
				
			||||||
 | 
					4. Note the Client ID and Client Secret
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Configuration:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = oauth
 | 
				
			||||||
 | 
					provider = google
 | 
				
			||||||
 | 
					client_id = 123456789-abcdefghijklmnop.apps.googleusercontent.com
 | 
				
			||||||
 | 
					client_secret = GOCSPX-your-client-secret-here
 | 
				
			||||||
 | 
					redirect_uri = http://your-server:8080/oauth/callback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Restrict to specific domain(s)
 | 
				
			||||||
 | 
					authorized_domains = example.com,mycompany.com
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Or restrict to specific users
 | 
				
			||||||
 | 
					authorized_users = admin@example.com,backup-admin@example.com
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### GitHub OAuth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Setup:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Go to GitHub Settings > Developer settings > [OAuth Apps](https://github.com/settings/developers)
 | 
				
			||||||
 | 
					2. Register a new application
 | 
				
			||||||
 | 
					3. Set Authorization callback URL: `http://your-server:8080/oauth/callback`
 | 
				
			||||||
 | 
					4. Note the Client ID and Client Secret
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Configuration:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = oauth
 | 
				
			||||||
 | 
					provider = github
 | 
				
			||||||
 | 
					client_id = your-github-client-id
 | 
				
			||||||
 | 
					client_secret = your-github-client-secret
 | 
				
			||||||
 | 
					redirect_uri = http://your-server:8080/oauth/callback
 | 
				
			||||||
 | 
					authorized_users = admin@example.com,devops@example.com
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### GitLab OAuth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Setup:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Go to GitLab User Settings > [Applications](https://gitlab.com/-/profile/applications)
 | 
				
			||||||
 | 
					2. Create application with scopes: `read_user`, `email`
 | 
				
			||||||
 | 
					3. Set Redirect URI: `http://your-server:8080/oauth/callback`
 | 
				
			||||||
 | 
					4. Note the Application ID and Secret
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Configuration:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = oauth
 | 
				
			||||||
 | 
					provider = gitlab
 | 
				
			||||||
 | 
					client_id = your-gitlab-application-id
 | 
				
			||||||
 | 
					client_secret = your-gitlab-secret
 | 
				
			||||||
 | 
					redirect_uri = http://your-server:8080/oauth/callback
 | 
				
			||||||
 | 
					authorized_domains = example.com
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Generic OAuth Provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For custom OAuth providers:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = oauth
 | 
				
			||||||
 | 
					provider = generic
 | 
				
			||||||
 | 
					client_id = your-client-id
 | 
				
			||||||
 | 
					client_secret = your-client-secret
 | 
				
			||||||
 | 
					redirect_uri = http://your-server:8080/oauth/callback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Custom endpoints
 | 
				
			||||||
 | 
					authorization_endpoint = https://auth.example.com/oauth/authorize
 | 
				
			||||||
 | 
					token_endpoint = https://auth.example.com/oauth/token
 | 
				
			||||||
 | 
					userinfo_endpoint = https://auth.example.com/oauth/userinfo
 | 
				
			||||||
 | 
					scopes = openid,email,profile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Authorization rules
 | 
				
			||||||
 | 
					authorized_domains = example.com
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Security Best Practices
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1. Use HTTPS in Production
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Always use a reverse proxy with TLS:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```nginx
 | 
				
			||||||
 | 
					server {
 | 
				
			||||||
 | 
					  listen 443 ssl http2;
 | 
				
			||||||
 | 
					  server_name tisbackup.example.com;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ssl_certificate /etc/letsencrypt/live/tisbackup.example.com/fullchain.pem;
 | 
				
			||||||
 | 
					  ssl_certificate_key /etc/letsencrypt/live/tisbackup.example.com/privkey.pem;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  location / {
 | 
				
			||||||
 | 
					    proxy_pass http://localhost:8080/;
 | 
				
			||||||
 | 
					    proxy_set_header X-Real-IP $remote_addr;
 | 
				
			||||||
 | 
					    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
				
			||||||
 | 
					    proxy_set_header X-Forwarded-Proto https;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2. Set Strong Flask Secret Key
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Generate secret
 | 
				
			||||||
 | 
					python3 -c "import secrets; print(secrets.token_hex(32))"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Set in environment
 | 
				
			||||||
 | 
					export TISBACKUP_SECRET_KEY=your-generated-secret-key
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 3. Protect Configuration Files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					chmod 600 /etc/tis/tisbackup_gui.ini
 | 
				
			||||||
 | 
					chmod 600 /etc/tis/users.txt  # if using Flask-Login
 | 
				
			||||||
 | 
					chown root:root /etc/tis/*.ini
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 4. Use Environment Variables for Secrets
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Instead of hardcoding secrets in config files:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = oauth
 | 
				
			||||||
 | 
					client_id = ${OAUTH_CLIENT_ID}
 | 
				
			||||||
 | 
					client_secret = ${OAUTH_CLIENT_SECRET}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Then set in Docker Compose:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```yaml
 | 
				
			||||||
 | 
					services:
 | 
				
			||||||
 | 
					  tisbackup_gui:
 | 
				
			||||||
 | 
					    environment:
 | 
				
			||||||
 | 
					      - OAUTH_CLIENT_ID=your-client-id
 | 
				
			||||||
 | 
					      - OAUTH_CLIENT_SECRET=your-client-secret
 | 
				
			||||||
 | 
					      - TISBACKUP_SECRET_KEY=your-secret-key
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 5. Regularly Rotate Credentials
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Change passwords/secrets every 90 days
 | 
				
			||||||
 | 
					- Rotate OAuth client secrets annually
 | 
				
			||||||
 | 
					- Review authorized users/domains regularly
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 6. Monitor Authentication Logs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Check logs for failed authentication attempts:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					docker logs tisbackup_gui | grep -i "auth"
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Troubleshooting
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Basic Auth Not Working
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Check bcrypt installation:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   uv sync --extra auth-basic
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. **Verify password hash:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   python3 -c "import bcrypt; print(bcrypt.checkpw(b'yourpassword', b'$2b$12$your-hash'))"
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. **Check browser credentials:**
 | 
				
			||||||
 | 
					   - Clear browser cache
 | 
				
			||||||
 | 
					   - Try incognito/private mode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Flask-Login Issues
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Users file not found:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   ls -la /etc/tis/users.txt
 | 
				
			||||||
 | 
					   chmod 600 /etc/tis/users.txt
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. **Module not found:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   uv sync --extra auth-login
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. **Session problems:**
 | 
				
			||||||
 | 
					   - Check `TISBACKUP_SECRET_KEY` is set
 | 
				
			||||||
 | 
					   - Ensure cookies are enabled
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### OAuth Problems
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Redirect URI mismatch:**
 | 
				
			||||||
 | 
					   - Ensure redirect URI in config matches OAuth app settings exactly
 | 
				
			||||||
 | 
					   - Check for http vs https mismatch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. **Unauthorized domain/user:**
 | 
				
			||||||
 | 
					   - Verify email matches `authorized_users` or domain matches `authorized_domains`
 | 
				
			||||||
 | 
					   - Check OAuth provider returns email in user info
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. **Module not found:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   uv sync --extra auth-oauth
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. **Token errors:**
 | 
				
			||||||
 | 
					   - Verify client ID and secret are correct
 | 
				
			||||||
 | 
					   - Check OAuth app is enabled
 | 
				
			||||||
 | 
					   - Ensure scopes are correct
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## API Access with Authentication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Basic Auth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					curl -u admin:password http://localhost:8080/api/backups
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### OAuth (with access token)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Not recommended for API access - use service account or API keys instead
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Recommendation for API Access
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For programmatic API access, use Basic Auth with a dedicated API user:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = basic
 | 
				
			||||||
 | 
					username = api-user
 | 
				
			||||||
 | 
					password = $2b$12$...
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Or implement API key authentication separately for API endpoints.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Migration Guide
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### From No Auth to Basic Auth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Add authentication section to config
 | 
				
			||||||
 | 
					2. Install bcrypt: `uv sync --extra auth-basic`
 | 
				
			||||||
 | 
					3. Restart service
 | 
				
			||||||
 | 
					4. Update client scripts with credentials
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### From Basic Auth to OAuth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Register OAuth application
 | 
				
			||||||
 | 
					2. Update configuration
 | 
				
			||||||
 | 
					3. Install dependencies: `uv sync --extra auth-oauth`
 | 
				
			||||||
 | 
					4. Test OAuth login flow
 | 
				
			||||||
 | 
					5. Update redirect URI if needed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### From Flask-Login to OAuth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Register OAuth application
 | 
				
			||||||
 | 
					2. Map user emails to OAuth provider
 | 
				
			||||||
 | 
					3. Update configuration
 | 
				
			||||||
 | 
					4. Test migration with test users first
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Support
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For issues or questions:
 | 
				
			||||||
 | 
					- Check logs: `docker logs tisbackup_gui`
 | 
				
			||||||
 | 
					- Review configuration syntax
 | 
				
			||||||
 | 
					- Verify dependencies are installed
 | 
				
			||||||
 | 
					- See [SECURITY_IMPROVEMENTS.md](../SECURITY_IMPROVEMENTS.md) for security context
 | 
				
			||||||
							
								
								
									
										156
									
								
								CLAUDE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								CLAUDE.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,156 @@
 | 
				
			|||||||
 | 
					# CLAUDE.md
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Project Overview
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					TISBackup is a server-side backup orchestration system written in Python. It executes scheduled backups of various data sources (databases, files, VMs) from remote Linux and Windows systems. The project consists of:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- A CLI tool ([tisbackup.py](tisbackup.py)) for executing backups, cleanup, and monitoring
 | 
				
			||||||
 | 
					- A Flask web GUI ([tisbackup_gui.py](tisbackup_gui.py)) for managing backups
 | 
				
			||||||
 | 
					- A pluggable backup driver architecture in [libtisbackup/](libtisbackup/)
 | 
				
			||||||
 | 
					- Task queue system using Huey with Redis ([tasks.py](tasks.py), [config.py](config.py))
 | 
				
			||||||
 | 
					- Docker-based deployment with cron scheduling
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Development Commands
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Dependency Management
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Install dependencies (uses uv)
 | 
				
			||||||
 | 
					uv sync --locked
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Update dependencies
 | 
				
			||||||
 | 
					uv lock
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Linting
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Run ruff linter
 | 
				
			||||||
 | 
					ruff check .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Auto-fix linting issues
 | 
				
			||||||
 | 
					ruff check --fix .
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Running the Application
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Web GUI (development):**
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					python3 tisbackup_gui.py
 | 
				
			||||||
 | 
					# Runs on port 8080, requires config at /etc/tis/tisbackup_gui.ini
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**CLI Commands:**
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Run backups
 | 
				
			||||||
 | 
					python3 tisbackup.py -c /etc/tis/tisbackup-config.ini backup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Run specific backup section
 | 
				
			||||||
 | 
					python3 tisbackup.py -c /etc/tis/tisbackup-config.ini -s section_name backup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Cleanup old backups
 | 
				
			||||||
 | 
					python3 tisbackup.py -c /etc/tis/tisbackup-config.ini cleanup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Check backup status (for Nagios)
 | 
				
			||||||
 | 
					python3 tisbackup.py -c /etc/tis/tisbackup-config.ini checknagios
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# List available backup drivers
 | 
				
			||||||
 | 
					python3 tisbackup.py listdrivers
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Docker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Build image
 | 
				
			||||||
 | 
					docker build . -t tisbackup:latest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Run via docker compose (see README.md for full setup)
 | 
				
			||||||
 | 
					docker compose up -d
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Architecture
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Core Components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Main Entry Points:**
 | 
				
			||||||
 | 
					- [tisbackup.py](tisbackup.py) - CLI application with argument parsing and action routing (backup, cleanup, checknagios, etc.)
 | 
				
			||||||
 | 
					- [tisbackup_gui.py](tisbackup_gui.py) - Flask web application providing UI for backup management and status monitoring
 | 
				
			||||||
 | 
					- [tasks.py](tasks.py) - Huey task definitions for async operations (export_backup)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Backup Driver System:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					All backup logic is implemented via driver classes in [libtisbackup/](libtisbackup/):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Base class: `backup_generic` in [common.py](libtisbackup/common.py:565) (abstract)
 | 
				
			||||||
 | 
					- Each driver inherits from `backup_generic` and implements specific backup logic
 | 
				
			||||||
 | 
					- Drivers are registered via the `register_driver()` decorator function
 | 
				
			||||||
 | 
					- Configuration is read from INI files using the `read_config()` method
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Available Drivers:**
 | 
				
			||||||
 | 
					- `backup_rsync` / `backup_rsync_ssh` - File-based backups via rsync
 | 
				
			||||||
 | 
					- `backup_rsync_btrfs` / `backup_rsync__btrfs_ssh` - Btrfs snapshot-based backups
 | 
				
			||||||
 | 
					- `backup_mysql` - MySQL database dumps
 | 
				
			||||||
 | 
					- `backup_pgsql` - PostgreSQL database dumps
 | 
				
			||||||
 | 
					- `backup_oracle` - Oracle database backups
 | 
				
			||||||
 | 
					- `backup_sqlserver` - SQL Server backups
 | 
				
			||||||
 | 
					- `backup_samba4` - Samba4 AD backups
 | 
				
			||||||
 | 
					- `backup_xva` / `backup_xcp_metadata` - XenServer VM backups
 | 
				
			||||||
 | 
					- `backup_vmdk` - VMware VMDK backups
 | 
				
			||||||
 | 
					- `backup_switch` - Network switch configuration backups
 | 
				
			||||||
 | 
					- `backup_null` - No-op driver for testing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**State Management:**
 | 
				
			||||||
 | 
					- SQLite database tracks backup history, status, and statistics
 | 
				
			||||||
 | 
					- `BackupStat` class in [common.py](libtisbackup/common.py) handles DB operations
 | 
				
			||||||
 | 
					- Database location: `{backup_base_dir}/log/tisbackup.sqlite`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Configuration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Two separate INI configuration files:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **tisbackup-config.ini** - Backup definitions
 | 
				
			||||||
 | 
					   - `[global]` section with defaults (backup_base_dir, backup_retention_time, maximum_backup_age)
 | 
				
			||||||
 | 
					   - One section per backup job with driver type and parameters
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. **tisbackup_gui.ini** - GUI settings
 | 
				
			||||||
 | 
					   - Points to tisbackup-config.ini location(s)
 | 
				
			||||||
 | 
					   - Defines admin email, base directories
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Task Queue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Uses Huey (Redis-backed) for async job processing
 | 
				
			||||||
 | 
					- Currently implements `run_export_backup` for exporting backups to external storage
 | 
				
			||||||
 | 
					- Task state tracked in tasks.sqlite
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Docker Deployment
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Two-container architecture:
 | 
				
			||||||
 | 
					- **tisbackup_gui**: Runs Flask web interface
 | 
				
			||||||
 | 
					- **tisbackup_cron**: Runs scheduled backups via cron (executes [backup.sh](backup.sh) at 03:59 daily)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Code Style
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Line length: 140 characters (configured in pyproject.toml)
 | 
				
			||||||
 | 
					- Ruff ignores: F401, F403, F405, E402, E701, E722, E741
 | 
				
			||||||
 | 
					- Python 3.13+ required
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Important Patterns
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Adding a new backup driver:**
 | 
				
			||||||
 | 
					1. Create `backup_<type>.py` in [libtisbackup/](libtisbackup/)
 | 
				
			||||||
 | 
					2. Inherit from `backup_generic`
 | 
				
			||||||
 | 
					3. Set class attributes: `type`, `required_params`, `optional_params`
 | 
				
			||||||
 | 
					4. Implement abstract methods: `do_backup()`, `cleanup()`, `checknagios()`
 | 
				
			||||||
 | 
					5. Register with `register_driver(backup_<type>)`
 | 
				
			||||||
 | 
					6. Import in [tisbackup.py](tisbackup.py)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**SSH Operations:**
 | 
				
			||||||
 | 
					- Uses paramiko for SSH connections
 | 
				
			||||||
 | 
					- Supports both RSA and DSA keys
 | 
				
			||||||
 | 
					- Private key path specified per backup section via `private_key` parameter
 | 
				
			||||||
 | 
					- Pre/post-exec hooks run remote commands via SSH
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Path Handling:**
 | 
				
			||||||
 | 
					- Module imports use sys.path manipulation to include lib/ and libtisbackup/
 | 
				
			||||||
 | 
					- All backup drivers expect absolute paths for backup_dir
 | 
				
			||||||
 | 
					- Backup directory structure: `{backup_base_dir}/{section_name}/{timestamp}/`
 | 
				
			||||||
@ -13,7 +13,6 @@ ENV UV_PYTHON_DOWNLOADS=never
 | 
				
			|||||||
RUN apt-get update && apt-get upgrade -y \
 | 
					RUN apt-get update && apt-get upgrade -y \
 | 
				
			||||||
   && apt-get install --no-install-recommends -y rsync ssh cron \
 | 
					   && apt-get install --no-install-recommends -y rsync ssh cron \
 | 
				
			||||||
   && rm -rf /var/lib/apt/lists/* \
 | 
					   && rm -rf /var/lib/apt/lists/* \
 | 
				
			||||||
   #&& /usr/local/bin/python3.13 -m pip install --no-cache-dir -r requirements.txt \
 | 
					 | 
				
			||||||
   && uv sync --locked --no-dev --no-install-project \
 | 
					   && uv sync --locked --no-dev --no-install-project \
 | 
				
			||||||
   && rm -f /bin/uv /bin/uvx \
 | 
					   && rm -f /bin/uv /bin/uvx \
 | 
				
			||||||
   && mkdir -p /var/spool/cron/crontabs \
 | 
					   && mkdir -p /var/spool/cron/crontabs \
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										536
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										536
									
								
								README.md
									
									
									
									
									
								
							@ -1,145 +1,483 @@
 | 
				
			|||||||
# TISBackup
 | 
					# TISBackup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
This is the repository of the TISBackup project, licensed under GPLv3.
 | 
					A comprehensive server-side backup orchestration system for managing automated backups of databases, files, and virtual machines across remote Linux and Windows systems.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
TISBackup is a python script to backup servers.
 | 
					[](https://www.gnu.org/licenses/gpl-3.0)
 | 
				
			||||||
 | 
					[](https://www.python.org/downloads/)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
It runs at regular intervals to retrieve different data types on remote hosts
 | 
					## Overview
 | 
				
			||||||
such as database dumps, files, virtual machine images and metadata.
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Install using Compose
 | 
					TISBackup is a Python-based backup solution that provides:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Clone that repository and build the pod image using the provided `Dockerfile`
 | 
					- **Pluggable backup drivers** for different data sources (databases, files, VMs)
 | 
				
			||||||
 | 
					- **Web-based management interface** for monitoring and controlling backups
 | 
				
			||||||
 | 
					- **CLI tool** for automation and scripting
 | 
				
			||||||
 | 
					- **Automated scheduling** via cron
 | 
				
			||||||
 | 
					- **Backup retention management** with configurable policies
 | 
				
			||||||
 | 
					- **Status monitoring** with Nagios integration
 | 
				
			||||||
 | 
					- **Docker deployment** for easy setup and isolation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```bash
 | 
					### Supported Backup Types
 | 
				
			||||||
docker build . -t tisbackup:latest
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
In another folder, create subfolders as following
 | 
					| Type | Description | Driver |
 | 
				
			||||||
 | 
					|------|-------------|--------|
 | 
				
			||||||
 | 
					| **Files & Directories** | rsync-based file backups | `rsync+ssh` |
 | 
				
			||||||
 | 
					| **Btrfs Snapshots** | Snapshot-based incremental backups | `rsync+btrfs+ssh` |
 | 
				
			||||||
 | 
					| **MySQL** | Database dumps via SSH | `mysql+ssh` |
 | 
				
			||||||
 | 
					| **PostgreSQL** | Database dumps via SSH | `pgsql+ssh` |
 | 
				
			||||||
 | 
					| **SQL Server** | SQL Server backups | `sqlserver+ssh` |
 | 
				
			||||||
 | 
					| **Oracle** | Oracle database backups | `oracle+ssh` |
 | 
				
			||||||
 | 
					| **Samba4 AD** | Active Directory backups | `samba4` |
 | 
				
			||||||
 | 
					| **XenServer VMs** | XVA exports and metadata | `xen-xva`, `xcp-dump-metadata` |
 | 
				
			||||||
 | 
					| **VMware** | VMDK backups | `vmdk` |
 | 
				
			||||||
 | 
					| **Network Devices** | Switch configuration backups | `switch` |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```bash
 | 
					## Quick Start
 | 
				
			||||||
mkdir -p /var/tisbackup/{backup/log,config,ssh}/
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
Expected structure
 | 
					### Prerequisites
 | 
				
			||||||
```
 | 
					 | 
				
			||||||
/var/tisbackup/
 | 
					 | 
				
			||||||
    └─backup/                   <-- backup location
 | 
					 | 
				
			||||||
    └─config/
 | 
					 | 
				
			||||||
      ├── tisbackup-config.ini  <-- backups config
 | 
					 | 
				
			||||||
      └── tisbackup_gui.ini     <-- tisbackup config
 | 
					 | 
				
			||||||
    └─ssh/
 | 
					 | 
				
			||||||
      ├── id_rsa                <-- SSH Key
 | 
					 | 
				
			||||||
      └── id_rsa.pub            <-- SSH PubKey
 | 
					 | 
				
			||||||
    compose.yaml
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
Adapt the compose.yml file to suits your needs, one pod act as the WebUI front end and the other as the crond scheduler
 | 
					- Docker and Docker Compose
 | 
				
			||||||
 | 
					- SSH access to remote servers
 | 
				
			||||||
 | 
					- Ed25519, ECDSA, or RSA SSH keys (DSA not supported)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```yaml
 | 
					### Installation
 | 
				
			||||||
services:
 | 
					 | 
				
			||||||
  tisbackup_gui:
 | 
					 | 
				
			||||||
    container_name: tisbackup_gui
 | 
					 | 
				
			||||||
    image: "tisbackup:latest"
 | 
					 | 
				
			||||||
    build: .
 | 
					 | 
				
			||||||
    volumes:
 | 
					 | 
				
			||||||
      - ./config/:/etc/tis/
 | 
					 | 
				
			||||||
      - ./backup/:/backup/
 | 
					 | 
				
			||||||
      - /etc/timezone:/etc/timezone:ro
 | 
					 | 
				
			||||||
      - /etc/localtime:/etc/localtime:ro
 | 
					 | 
				
			||||||
    restart: unless-stopped
 | 
					 | 
				
			||||||
    ports:
 | 
					 | 
				
			||||||
      - 9980:8080
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  tisbackup_cron:
 | 
					1. **Clone the repository:**
 | 
				
			||||||
    container_name: tisbackup_cron
 | 
					   ```bash
 | 
				
			||||||
    image: "tisbackup:latest"
 | 
					   git clone https://github.com/tranquilit/TISbackup.git
 | 
				
			||||||
    build: .
 | 
					   cd TISbackup
 | 
				
			||||||
    volumes:
 | 
					   ```
 | 
				
			||||||
      - ./config/:/etc/tis/
 | 
					 | 
				
			||||||
      - ./ssh/:/config_ssh/
 | 
					 | 
				
			||||||
      - ./backup/:/backup/
 | 
					 | 
				
			||||||
      - /etc/timezone:/etc/timezone:ro
 | 
					 | 
				
			||||||
      - /etc/localtime:/etc/localtime:ro
 | 
					 | 
				
			||||||
    restart: always
 | 
					 | 
				
			||||||
    command: "/bin/bash /opt/tisbackup/cron.sh"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
```
 | 
					2. **Build the Docker image:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   docker build . -t tisbackup:latest
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. **Create directory structure:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   mkdir -p /var/tisbackup/{backup/log,config,ssh}
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   Expected structure:
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					   /var/tisbackup/
 | 
				
			||||||
 | 
					   ├── backup/              # Backup storage location
 | 
				
			||||||
 | 
					   │   └── log/             # SQLite database and logs
 | 
				
			||||||
 | 
					   ├── config/              # Configuration files
 | 
				
			||||||
 | 
					   │   ├── tisbackup-config.ini
 | 
				
			||||||
 | 
					   │   └── tisbackup_gui.ini
 | 
				
			||||||
 | 
					   ├── ssh/                 # SSH keys
 | 
				
			||||||
 | 
					   │   ├── id_ed25519       # Private key (Ed25519 recommended)
 | 
				
			||||||
 | 
					   │   └── id_ed25519.pub   # Public key
 | 
				
			||||||
 | 
					   └── compose.yaml         # Docker Compose configuration
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. **Generate SSH keys:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   # Ed25519 (recommended - most secure and modern)
 | 
				
			||||||
 | 
					   ssh-keygen -t ed25519 -f /var/tisbackup/ssh/id_ed25519 -C "tisbackup@yourserver"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   # Or ECDSA (also secure)
 | 
				
			||||||
 | 
					   ssh-keygen -t ecdsa -b 521 -f /var/tisbackup/ssh/id_ecdsa -C "tisbackup@yourserver"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   # Or RSA (legacy support, minimum 4096 bits)
 | 
				
			||||||
 | 
					   ssh-keygen -t rsa -b 4096 -f /var/tisbackup/ssh/id_rsa -C "tisbackup@yourserver"
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   ⚠️ **Note:** DSA keys are no longer supported due to security vulnerabilities.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					5. **Deploy public key to remote servers:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   ssh-copy-id -i /var/tisbackup/ssh/id_ed25519.pub root@remote-server
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					6. **Generate Flask secret key:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   python3 -c "import secrets; print(secrets.token_hex(32))"
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					   Save this key for the next step.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					7. **Create Docker Compose configuration:**
 | 
				
			||||||
 | 
					   ```yaml
 | 
				
			||||||
 | 
					   # /var/tisbackup/compose.yaml
 | 
				
			||||||
 | 
					   services:
 | 
				
			||||||
 | 
					     tisbackup_gui:
 | 
				
			||||||
 | 
					       container_name: tisbackup_gui
 | 
				
			||||||
 | 
					       image: "tisbackup:latest"
 | 
				
			||||||
 | 
					       volumes:
 | 
				
			||||||
 | 
					         - ./config/:/etc/tis/
 | 
				
			||||||
 | 
					         - ./backup/:/backup/
 | 
				
			||||||
 | 
					         - /etc/timezone:/etc/timezone:ro
 | 
				
			||||||
 | 
					         - /etc/localtime:/etc/localtime:ro
 | 
				
			||||||
 | 
					       environment:
 | 
				
			||||||
 | 
					         # SECURITY: Use the secret key you generated above
 | 
				
			||||||
 | 
					         - TISBACKUP_SECRET_KEY=your-secret-key-here
 | 
				
			||||||
 | 
					       restart: unless-stopped
 | 
				
			||||||
 | 
					       ports:
 | 
				
			||||||
 | 
					         - 9980:8080
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     tisbackup_cron:
 | 
				
			||||||
 | 
					       container_name: tisbackup_cron
 | 
				
			||||||
 | 
					       image: "tisbackup:latest"
 | 
				
			||||||
 | 
					       volumes:
 | 
				
			||||||
 | 
					         - ./config/:/etc/tis/
 | 
				
			||||||
 | 
					         - ./ssh/:/config_ssh/
 | 
				
			||||||
 | 
					         - ./backup/:/backup/
 | 
				
			||||||
 | 
					         - /etc/timezone:/etc/timezone:ro
 | 
				
			||||||
 | 
					         - /etc/localtime:/etc/localtime:ro
 | 
				
			||||||
 | 
					       restart: always
 | 
				
			||||||
 | 
					       command: "/bin/bash /opt/tisbackup/cron.sh"
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					8. **Configure backups:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   Create `/var/tisbackup/config/tisbackup-config.ini`:
 | 
				
			||||||
 | 
					   ```ini
 | 
				
			||||||
 | 
					   [global]
 | 
				
			||||||
 | 
					   backup_base_dir = /backup/
 | 
				
			||||||
 | 
					   # Backup retention in days
 | 
				
			||||||
 | 
					   backup_retention_time = 90
 | 
				
			||||||
 | 
					   # Maximum backup age for Nagios checks (hours)
 | 
				
			||||||
 | 
					   maximum_backup_age = 30
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   # Example: File backup via rsync
 | 
				
			||||||
 | 
					   [webserver-files]
 | 
				
			||||||
 | 
					   type = rsync+ssh
 | 
				
			||||||
 | 
					   server_name = webserver.example.com
 | 
				
			||||||
 | 
					   remote_dir = /var/www/
 | 
				
			||||||
 | 
					   compression = True
 | 
				
			||||||
 | 
					   exclude_list = "/var/www/cache/**","/var/www/temp/**"
 | 
				
			||||||
 | 
					   private_key = /config_ssh/id_ed25519
 | 
				
			||||||
 | 
					   ssh_port = 22
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   # Example: MySQL database backup
 | 
				
			||||||
 | 
					   [database-mysql]
 | 
				
			||||||
 | 
					   type = mysql+ssh
 | 
				
			||||||
 | 
					   server_name = db.example.com
 | 
				
			||||||
 | 
					   db_name = production_db
 | 
				
			||||||
 | 
					   db_user = backup_user
 | 
				
			||||||
 | 
					   db_passwd = backup_password
 | 
				
			||||||
 | 
					   private_key = /config_ssh/id_ed25519
 | 
				
			||||||
 | 
					   ssh_port = 22
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   Create `/var/tisbackup/config/tisbackup_gui.ini`:
 | 
				
			||||||
 | 
					   ```ini
 | 
				
			||||||
 | 
					   [general]
 | 
				
			||||||
 | 
					   config_tisbackup = /etc/tis/tisbackup-config.ini
 | 
				
			||||||
 | 
					   sections =
 | 
				
			||||||
 | 
					   ADMIN_EMAIL = admin@example.com
 | 
				
			||||||
 | 
					   base_config_dir = /etc/tis/
 | 
				
			||||||
 | 
					   backup_base_dir = /backup/
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					9. **Start services:**
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   cd /var/tisbackup
 | 
				
			||||||
 | 
					   docker compose up -d
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					10. **Access web interface:**
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
 | 
					    http://localhost:9980
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Configuration
 | 
					## Configuration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 * Provide an SSH key and store it in `./ssh`
 | 
					### Backup Types Configuration
 | 
				
			||||||
 * Setup config files in the `./config` directory
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
**tisbackup-config.ini**
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### File Backups (rsync+ssh)
 | 
				
			||||||
```ini
 | 
					```ini
 | 
				
			||||||
[global]
 | 
					[backup-name]
 | 
				
			||||||
backup_base_dir = /backup/
 | 
					type = rsync+ssh
 | 
				
			||||||
 | 
					server_name = hostname.example.com
 | 
				
			||||||
# backup retention in days
 | 
					remote_dir = /path/to/backup/
 | 
				
			||||||
backup_retention_time=90
 | 
					compression = True
 | 
				
			||||||
 | 
					exclude_list = "/path/exclude1/**","/path/exclude2/**"
 | 
				
			||||||
# for nagios check in hours
 | 
					private_key = /config_ssh/id_ed25519
 | 
				
			||||||
maximum_backup_age=30
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[srvads-poudlard-samba]
 | 
					 | 
				
			||||||
type=rsync+ssh
 | 
					 | 
				
			||||||
server_name=srvads.poudlard.lan
 | 
					 | 
				
			||||||
remote_dir=/var/lib/samba/
 | 
					 | 
				
			||||||
compression=True
 | 
					 | 
				
			||||||
;exclude_list="/proc/**","/sys/**","/dev/**"
 | 
					 | 
				
			||||||
private_key=/config_ssh/id_rsa
 | 
					 | 
				
			||||||
ssh_port = 22
 | 
					ssh_port = 22
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**tisbackup_gui.ini**
 | 
					#### Btrfs Snapshots (rsync+btrfs+ssh)
 | 
				
			||||||
```ini
 | 
					```ini
 | 
				
			||||||
[general]
 | 
					[backup-name]
 | 
				
			||||||
config_tisbackup= /etc/tis/tisbackup-config.ini
 | 
					type = rsync+btrfs+ssh
 | 
				
			||||||
sections=
 | 
					server_name = hostname.example.com
 | 
				
			||||||
ADMIN_EMAIL=josebove@internet.fr
 | 
					remote_dir = /mnt/btrfs/data/
 | 
				
			||||||
base_config_dir= /etc/tis/
 | 
					compression = True
 | 
				
			||||||
backup_base_dir=/backup/
 | 
					private_key = /config_ssh/id_ed25519
 | 
				
			||||||
 | 
					ssh_port = 22
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Run!
 | 
					#### MySQL Database (mysql+ssh)
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[backup-name]
 | 
				
			||||||
 | 
					type = mysql+ssh
 | 
				
			||||||
 | 
					server_name = hostname.example.com
 | 
				
			||||||
 | 
					db_name = database_name
 | 
				
			||||||
 | 
					db_user = backup_user
 | 
				
			||||||
 | 
					db_passwd = backup_password
 | 
				
			||||||
 | 
					private_key = /config_ssh/id_ed25519
 | 
				
			||||||
 | 
					ssh_port = 22
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### PostgreSQL Database (pgsql+ssh)
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[backup-name]
 | 
				
			||||||
 | 
					type = pgsql+ssh
 | 
				
			||||||
 | 
					server_name = hostname.example.com
 | 
				
			||||||
 | 
					db_name = database_name
 | 
				
			||||||
 | 
					private_key = /config_ssh/id_ed25519
 | 
				
			||||||
 | 
					ssh_port = 22
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### XenServer VM (xen-xva)
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[backup-name]
 | 
				
			||||||
 | 
					type = xen-xva
 | 
				
			||||||
 | 
					server_name = vm-name
 | 
				
			||||||
 | 
					xcphost = xenserver.example.com
 | 
				
			||||||
 | 
					password_file = /etc/tis/xen-password
 | 
				
			||||||
 | 
					private_key = /config_ssh/id_ed25519
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Pre/Post Execution Hooks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					You can execute commands before and after backups:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```ini
 | 
				
			||||||
 | 
					[backup-name]
 | 
				
			||||||
 | 
					type = rsync+ssh
 | 
				
			||||||
 | 
					server_name = hostname.example.com
 | 
				
			||||||
 | 
					remote_dir = /data/
 | 
				
			||||||
 | 
					private_key = /config_ssh/id_ed25519
 | 
				
			||||||
 | 
					preexec = systemctl stop application
 | 
				
			||||||
 | 
					postexec = systemctl start application
 | 
				
			||||||
 | 
					remote_user = root
 | 
				
			||||||
 | 
					ssh_port = 22
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## CLI Usage
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Running Backups
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```bash
 | 
					```bash
 | 
				
			||||||
docker compose up -d
 | 
					# Run all backups
 | 
				
			||||||
 | 
					docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py backup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Run specific backup
 | 
				
			||||||
 | 
					docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -s backup-name backup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Dry run
 | 
				
			||||||
 | 
					docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -d backup
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## NGINX reverse-proxy
 | 
					### Cleanup Old Backups
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Sample config file
 | 
					```bash
 | 
				
			||||||
 | 
					# Remove backups older than retention period
 | 
				
			||||||
 | 
					docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py cleanup
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Nagios Monitoring
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Check backup status
 | 
				
			||||||
 | 
					docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py checknagios
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Check specific backup
 | 
				
			||||||
 | 
					docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -s backup-name checknagios
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### List Available Drivers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py listdrivers
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Backup Statistics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Dump statistics for last 20 backups
 | 
				
			||||||
 | 
					docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py dumpstat
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Specify number of backups
 | 
				
			||||||
 | 
					docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -n 50 dumpstat
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Development
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Prerequisites
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Python 3.13+
 | 
				
			||||||
 | 
					- uv (Python package manager)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Setup Development Environment
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Install dependencies
 | 
				
			||||||
 | 
					uv sync --locked
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Run linter
 | 
				
			||||||
 | 
					uv run ruff check .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Auto-fix linting issues
 | 
				
			||||||
 | 
					uv run ruff check --fix .
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Running Locally
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Run web GUI (requires config at /etc/tis/tisbackup_gui.ini)
 | 
				
			||||||
 | 
					python3 tisbackup_gui.py
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Run CLI
 | 
				
			||||||
 | 
					python3 tisbackup.py -c /etc/tis/tisbackup-config.ini backup
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Security
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					TISBackup implements several security best practices:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### SSH Key Security
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Ed25519 keys are recommended** (most secure, modern algorithm)
 | 
				
			||||||
 | 
					- ECDSA and RSA keys are supported
 | 
				
			||||||
 | 
					- **DSA keys are explicitly not supported** (deprecated, insecure)
 | 
				
			||||||
 | 
					- Key algorithm priority: Ed25519 → ECDSA → RSA
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Flask Session Security
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Secret key loaded from `TISBACKUP_SECRET_KEY` environment variable
 | 
				
			||||||
 | 
					- Falls back to cryptographically secure random key if not set
 | 
				
			||||||
 | 
					- No hardcoded secrets in source code
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Command Execution Safety
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- All system commands use `subprocess.run()` with list arguments
 | 
				
			||||||
 | 
					- Input validation for device paths and partition names
 | 
				
			||||||
 | 
					- Timeout protection on all subprocess calls
 | 
				
			||||||
 | 
					- No use of `shell=True` in new code
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Best Practices
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Use Ed25519 keys** for all SSH connections
 | 
				
			||||||
 | 
					2. **Set unique Flask secret key** via environment variable
 | 
				
			||||||
 | 
					3. **Use reverse proxy** (nginx) with TLS for web interface
 | 
				
			||||||
 | 
					4. **Restrict network access** to backup server
 | 
				
			||||||
 | 
					5. **Regular security updates** of base Docker image
 | 
				
			||||||
 | 
					6. **Monitor backup logs** for suspicious activity
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Reverse Proxy Setup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Example nginx configuration for HTTPS access:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```nginx
 | 
					```nginx
 | 
				
			||||||
server {
 | 
					server {
 | 
				
			||||||
  listen 443 ssl http2;
 | 
					  listen 443 ssl http2;
 | 
				
			||||||
  # Remove '#' in the next line to enable IPv6
 | 
					  server_name tisbackup.example.com;
 | 
				
			||||||
  # listen [::]:443 ssl http2;
 | 
					 | 
				
			||||||
  server_name tisbackup.poudlard.lan;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ssl_certificate /etc/letsencrypt/live/tisbackup.poudlard.lan/fullchain.pem; # managed by Certbot
 | 
					  ssl_certificate /etc/letsencrypt/live/tisbackup.example.com/fullchain.pem;
 | 
				
			||||||
  ssl_certificate_key /etc/letsencrypt/live/tisbackup.poudlard.lan/privkey.pem; # managed by Certbot
 | 
					  ssl_certificate_key /etc/letsencrypt/live/tisbackup.example.com/privkey.pem;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # Security headers
 | 
				
			||||||
 | 
					  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
 | 
				
			||||||
 | 
					  add_header X-Frame-Options "SAMEORIGIN" always;
 | 
				
			||||||
 | 
					  add_header X-Content-Type-Options "nosniff" always;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  location / {
 | 
					  location / {
 | 
				
			||||||
    proxy_set_header   X-Real-IP $remote_addr;
 | 
					    proxy_set_header X-Real-IP $remote_addr;
 | 
				
			||||||
    proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
					    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
				
			||||||
    proxy_set_header   Host $host;
 | 
					    proxy_set_header Host $host;
 | 
				
			||||||
    proxy_pass         http://localhost:9980/;
 | 
					    proxy_pass http://localhost:9980/;
 | 
				
			||||||
    proxy_http_version 1.1;
 | 
					    proxy_http_version 1.1;
 | 
				
			||||||
    proxy_set_header   Upgrade $http_upgrade;
 | 
					    proxy_set_header Upgrade $http_upgrade;
 | 
				
			||||||
    proxy_set_header   Connection "upgrade";
 | 
					    proxy_set_header Connection "upgrade";
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Architecture
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					TISBackup uses a modular driver-based architecture:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## About
 | 
					- **Core CLI** ([tisbackup.py](tisbackup.py)): Backup orchestration and scheduling
 | 
				
			||||||
 | 
					- **Web GUI** ([tisbackup_gui.py](tisbackup_gui.py)): Flask-based management interface
 | 
				
			||||||
 | 
					- **Backup Drivers** ([libtisbackup/](libtisbackup/)): Pluggable modules for different backup types
 | 
				
			||||||
 | 
					- **Task Queue** ([tasks.py](tasks.py), [config.py](config.py)): Async job processing with Huey
 | 
				
			||||||
 | 
					- **State Database**: SQLite for tracking backup history and statistics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[Tranquil IT](contact_at_tranquil_it) is the original author of TISBackup.
 | 
					Each backup type is implemented as a driver class inheriting from `backup_generic`, allowing easy extension for new backup sources.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
The documentation is provided under the license CC-BY-SA and can be found
 | 
					## Troubleshooting
 | 
				
			||||||
on [readthedoc](https://tisbackup.readthedocs.io/en/latest/index.html).
 | 
					
 | 
				
			||||||
 | 
					### Backups Not Running
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Check cron logs:
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   docker logs tisbackup_cron
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. Verify SSH connectivity:
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   docker exec tisbackup_cron ssh -i /config_ssh/id_ed25519 root@remote-server
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. Check backup configuration:
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -c /etc/tis/tisbackup-config.ini -d backup
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Web Interface Not Accessible
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Check GUI container logs:
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   docker logs tisbackup_gui
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. Verify port mapping:
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   docker ps | grep tisbackup_gui
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. Check configuration:
 | 
				
			||||||
 | 
					   ```bash
 | 
				
			||||||
 | 
					   docker exec tisbackup_gui cat /etc/tis/tisbackup_gui.ini
 | 
				
			||||||
 | 
					   ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Permission Errors
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ensure proper file permissions:
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					chmod 600 /var/tisbackup/ssh/id_ed25519
 | 
				
			||||||
 | 
					chmod 644 /var/tisbackup/ssh/id_ed25519.pub
 | 
				
			||||||
 | 
					chown -R root:root /var/tisbackup/
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Contributing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Contributions are welcome! Please:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Fork the repository
 | 
				
			||||||
 | 
					2. Create a feature branch
 | 
				
			||||||
 | 
					3. Follow the existing code style (use `ruff` for linting)
 | 
				
			||||||
 | 
					4. Add tests if applicable
 | 
				
			||||||
 | 
					5. Submit a pull request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## License
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					TISBackup is licensed under the GNU General Public License v3.0 (GPLv3).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					See [LICENSE](LICENSE) for the full license text.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Support & Documentation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Documentation**: [https://tisbackup.readthedocs.io](https://tisbackup.readthedocs.io/en/latest/index.html)
 | 
				
			||||||
 | 
					- **Issues**: [GitHub Issues](https://github.com/tranquilit/TISbackup/issues)
 | 
				
			||||||
 | 
					- **Original Author**: [Tranquil IT](https://www.tranquil.it)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Credits
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Developed by Tranquil IT for system administrators managing backup infrastructure.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Security improvements and modernization contributed by the community.
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										272
									
								
								SECURITY_IMPROVEMENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								SECURITY_IMPROVEMENTS.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,272 @@
 | 
				
			|||||||
 | 
					# Security and Code Quality Improvements
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This document summarizes the security and code quality improvements made to TISBackup.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Completed Improvements (High Priority)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1. Replaced `os.popen()` with `subprocess.run()`
 | 
				
			||||||
 | 
					**Files Modified:** [tisbackup_gui.py](tisbackup_gui.py)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Changes:**
 | 
				
			||||||
 | 
					- Replaced deprecated `os.popen()` calls with modern `subprocess.run()`
 | 
				
			||||||
 | 
					- All subprocess calls now use list arguments instead of shell strings
 | 
				
			||||||
 | 
					- Added timeout protection (5-30 seconds depending on operation)
 | 
				
			||||||
 | 
					- Proper error handling with try/except blocks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Before:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					for line in os.popen("udevadm info -q env -n %s" % name):
 | 
				
			||||||
 | 
					    # Process output
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**After:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					result = subprocess.run(
 | 
				
			||||||
 | 
					    ["udevadm", "info", "-q", "env", "-n", name],
 | 
				
			||||||
 | 
					    capture_output=True,
 | 
				
			||||||
 | 
					    text=True,
 | 
				
			||||||
 | 
					    check=True,
 | 
				
			||||||
 | 
					    timeout=5
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					for line in result.stdout.splitlines():
 | 
				
			||||||
 | 
					    # Process output
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Security Impact:** Prevents command injection vulnerabilities
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2. Replaced `os.system()` with `subprocess.run()`
 | 
				
			||||||
 | 
					**Files Modified:** [tasks.py](tasks.py), [libtisbackup/backup_xva.py](libtisbackup/backup_xva.py)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Changes:**
 | 
				
			||||||
 | 
					- [tasks.py:37](tasks.py#L37): Changed `os.system("/bin/umount %s")` to `subprocess.run(["/bin/umount", mount_point])`
 | 
				
			||||||
 | 
					- [backup_xva.py:199](libtisbackup/backup_xva.py#L199): Changed `os.system('tar tf "%s"')` to `subprocess.run(["tar", "tf", filename_temp])`
 | 
				
			||||||
 | 
					- Added proper error handling and logging
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Security Impact:** Eliminates command injection risk from potentially user-controlled mount points and filenames
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 3. Added Input Validation
 | 
				
			||||||
 | 
					**Files Modified:** [tisbackup_gui.py](tisbackup_gui.py)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Changes:**
 | 
				
			||||||
 | 
					- Added regex validation for device/partition names: `^/dev/sd[a-z]1?$`
 | 
				
			||||||
 | 
					- Validates partition names before using in mount/unmount operations
 | 
				
			||||||
 | 
					- Prevents path traversal and command injection attacks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Example:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					# Validate partition name to prevent command injection
 | 
				
			||||||
 | 
					if not re.match(r"^/dev/sd[a-z]1$", partition):
 | 
				
			||||||
 | 
					    continue
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Security Impact:** Prevents malicious input from reaching system commands
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 4. Fixed File Operations with Context Managers
 | 
				
			||||||
 | 
					**Files Modified:** [tisbackup_gui.py](tisbackup_gui.py)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Before:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					line = open(elem).readline()
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**After:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					with open(elem) as f:
 | 
				
			||||||
 | 
					    line = f.readline()
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Impact:** Ensures files are properly closed, prevents resource leaks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 5. Improved `run_command()` Function
 | 
				
			||||||
 | 
					**Files Modified:** [tisbackup_gui.py:415-453](tisbackup_gui.py#L415)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Changes:**
 | 
				
			||||||
 | 
					- Now accepts list arguments for safe command execution
 | 
				
			||||||
 | 
					- Backward compatible with string commands (marked as legacy)
 | 
				
			||||||
 | 
					- Added timeout protection (30 seconds)
 | 
				
			||||||
 | 
					- Better error handling and reporting
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Security Impact:** Provides safe command execution interface while maintaining backward compatibility
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 6. Removed Wildcard Import
 | 
				
			||||||
 | 
					**Files Modified:** [tisbackup_gui.py](tisbackup_gui.py)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Before:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					from shutil import *
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**After:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					import shutil
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Impact:** Cleaner namespace, easier to track dependencies
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 7. Fixed Hardcoded Secret Key
 | 
				
			||||||
 | 
					**Files Modified:** [tisbackup_gui.py:67-79](tisbackup_gui.py#L67), [README.md](README.md)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Before:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					app.secret_key = "fsiqefiuqsefARZ4Zfesfe34234dfzefzfe"
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**After:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					SECRET_KEY = os.environ.get("TISBACKUP_SECRET_KEY")
 | 
				
			||||||
 | 
					if not SECRET_KEY:
 | 
				
			||||||
 | 
					    import secrets
 | 
				
			||||||
 | 
					    SECRET_KEY = secrets.token_hex(32)
 | 
				
			||||||
 | 
					    logging.warning(
 | 
				
			||||||
 | 
					        "TISBACKUP_SECRET_KEY environment variable not set. "
 | 
				
			||||||
 | 
					        "Using a randomly generated secret key. "
 | 
				
			||||||
 | 
					        "Sessions will not persist across application restarts. "
 | 
				
			||||||
 | 
					        "Set TISBACKUP_SECRET_KEY environment variable for production use."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					app.secret_key = SECRET_KEY
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Changes:**
 | 
				
			||||||
 | 
					- Reads secret key from `TISBACKUP_SECRET_KEY` environment variable
 | 
				
			||||||
 | 
					- Falls back to cryptographically secure random key if not set
 | 
				
			||||||
 | 
					- Logs warning when using random key (sessions won't persist across restarts)
 | 
				
			||||||
 | 
					- Uses Python's `secrets` module for cryptographically strong random generation
 | 
				
			||||||
 | 
					- Updated README.md with setup instructions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Setup Instructions:**
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Generate a secure secret key
 | 
				
			||||||
 | 
					python3 -c "import secrets; print(secrets.token_hex(32))"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Set in Docker Compose (compose.yml)
 | 
				
			||||||
 | 
					environment:
 | 
				
			||||||
 | 
					  - TISBACKUP_SECRET_KEY=your-generated-key-here
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Or export in shell
 | 
				
			||||||
 | 
					export TISBACKUP_SECRET_KEY=your-generated-key-here
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Security Impact:** Eliminates hardcoded secret in source code, prevents session hijacking and CSRF attacks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 8. Modernized SSH Key Algorithm Support
 | 
				
			||||||
 | 
					**Files Modified:** [libtisbackup/common.py](libtisbackup/common.py#L140), all backup drivers, [README.md](README.md)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Before:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					try:
 | 
				
			||||||
 | 
					    mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
 | 
				
			||||||
 | 
					except paramiko.SSHException:
 | 
				
			||||||
 | 
					    mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**After:**
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					def load_ssh_private_key(private_key_path):
 | 
				
			||||||
 | 
					    """Load SSH private key with modern algorithm support.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Tries to load the key in order of preference:
 | 
				
			||||||
 | 
					    1. Ed25519 (most secure, modern)
 | 
				
			||||||
 | 
					    2. ECDSA (secure, widely supported)
 | 
				
			||||||
 | 
					    3. RSA (legacy, still secure with sufficient key size)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    DSA is not supported as it's deprecated and insecure.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    key_types = [
 | 
				
			||||||
 | 
					        ("Ed25519", paramiko.Ed25519Key),
 | 
				
			||||||
 | 
					        ("ECDSA", paramiko.ECDSAKey),
 | 
				
			||||||
 | 
					        ("RSA", paramiko.RSAKey),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for key_name, key_class in key_types:
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return key_class.from_private_key_file(private_key_path)
 | 
				
			||||||
 | 
					        except paramiko.SSHException:
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    raise paramiko.SSHException(
 | 
				
			||||||
 | 
					        f"Unable to load private key. "
 | 
				
			||||||
 | 
					        f"Supported formats: Ed25519 (recommended), ECDSA, RSA. "
 | 
				
			||||||
 | 
					        f"DSA keys are no longer supported."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Changes:**
 | 
				
			||||||
 | 
					- Created centralized `load_ssh_private_key()` helper function
 | 
				
			||||||
 | 
					- Updated all SSH key loading locations across codebase:
 | 
				
			||||||
 | 
					  - [common.py](libtisbackup/common.py): `do_preexec`, `do_postexec`, `run_remote_command`
 | 
				
			||||||
 | 
					  - [backup_mysql.py](libtisbackup/backup_mysql.py)
 | 
				
			||||||
 | 
					  - [backup_pgsql.py](libtisbackup/backup_pgsql.py)
 | 
				
			||||||
 | 
					  - [backup_sqlserver.py](libtisbackup/backup_sqlserver.py)
 | 
				
			||||||
 | 
					  - [backup_oracle.py](libtisbackup/backup_oracle.py)
 | 
				
			||||||
 | 
					  - [backup_samba4.py](libtisbackup/backup_samba4.py)
 | 
				
			||||||
 | 
					- Removed deprecated DSA key support
 | 
				
			||||||
 | 
					- Added Ed25519 as preferred algorithm
 | 
				
			||||||
 | 
					- Added ECDSA as second choice
 | 
				
			||||||
 | 
					- RSA remains supported for compatibility
 | 
				
			||||||
 | 
					- Clear error message indicating DSA is no longer supported
 | 
				
			||||||
 | 
					- Updated README.md with key generation instructions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**SSH Key Generation:**
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					# Ed25519 (recommended)
 | 
				
			||||||
 | 
					ssh-keygen -t ed25519 -f ./ssh/id_ed25519 -C "tisbackup"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ECDSA (also secure)
 | 
				
			||||||
 | 
					ssh-keygen -t ecdsa -b 521 -f ./ssh/id_ecdsa
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# RSA (legacy, minimum 4096 bits)
 | 
				
			||||||
 | 
					ssh-keygen -t rsa -b 4096 -f ./ssh/id_rsa
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Security Impact:**
 | 
				
			||||||
 | 
					- Eliminates support for vulnerable DSA algorithm (1024-bit limit, FIPS deprecated)
 | 
				
			||||||
 | 
					- Prioritizes Ed25519 (fast, secure, resistant to timing attacks)
 | 
				
			||||||
 | 
					- Supports ECDSA as secure alternative
 | 
				
			||||||
 | 
					- Maintains RSA compatibility for legacy systems
 | 
				
			||||||
 | 
					- Clear migration path for users with old keys
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Remaining Security Issues (Critical - Not Fixed)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1. **No Authentication on Flask Routes**
 | 
				
			||||||
 | 
					All routes are publicly accessible without authentication.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Recommendation:** Implement Flask-Login or similar authentication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2. **Insecure SSH Host Key Policy** ([libtisbackup/common.py:649](libtisbackup/common.py#L649))
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					**Recommendation:** Use proper host key verification with known_hosts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 3. **Command Injection in Legacy Code**
 | 
				
			||||||
 | 
					Multiple files still use `subprocess.call(shell_string, shell=True)` and `subprocess.Popen(..., shell=True)`:
 | 
				
			||||||
 | 
					- [libtisbackup/common.py:128](libtisbackup/common.py#L128)
 | 
				
			||||||
 | 
					- [libtisbackup/common.py:883](libtisbackup/common.py#L883)
 | 
				
			||||||
 | 
					- [libtisbackup/common.py:986](libtisbackup/common.py#L986)
 | 
				
			||||||
 | 
					- [libtisbackup/backup_rsync.py:176](libtisbackup/backup_rsync.py#L176)
 | 
				
			||||||
 | 
					- [libtisbackup/backup_rsync_btrfs.py](libtisbackup/backup_rsync_btrfs.py) (multiple locations)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Recommendation:** Refactor to use list arguments without shell=True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Code Quality Issues Remaining
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Global State Management** - Use Flask application context instead
 | 
				
			||||||
 | 
					2. **Wildcard imports from common** - `from libtisbackup.common import *`
 | 
				
			||||||
 | 
					3. **Configuration loaded at module level** - Should use application factory pattern
 | 
				
			||||||
 | 
					4. **Duplicated code** - `read_config()` and `read_all_configs()` share significant logic
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Testing Recommendations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Before deploying these changes:
 | 
				
			||||||
 | 
					1. Test USB disk detection and mounting functionality
 | 
				
			||||||
 | 
					2. Test backup export operations
 | 
				
			||||||
 | 
					3. Verify XVA backup tar validation
 | 
				
			||||||
 | 
					4. Test error handling for invalid device names
 | 
				
			||||||
 | 
					5. Verify backward compatibility with existing configurations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Migration Notes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					All changes are backward compatible. The `run_command()` function accepts both:
 | 
				
			||||||
 | 
					- New format: `run_command(["/bin/command", "arg1", "arg2"])`
 | 
				
			||||||
 | 
					- Legacy format: `run_command("/bin/command arg1 arg2")` (less secure, marked for deprecation)
 | 
				
			||||||
							
								
								
									
										372
									
								
								docs-sphinx-rst/source/authentication.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										372
									
								
								docs-sphinx-rst/source/authentication.rst
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,372 @@
 | 
				
			|||||||
 | 
					.. Reminder for header structure:
 | 
				
			||||||
 | 
					  Level 1: ====================
 | 
				
			||||||
 | 
					  Level 2: --------------------
 | 
				
			||||||
 | 
					  Level 3: ++++++++++++++++++++
 | 
				
			||||||
 | 
					  Level 4: """"""""""""""""""""
 | 
				
			||||||
 | 
					  Level 5: ^^^^^^^^^^^^^^^^^^^^
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. meta::
 | 
				
			||||||
 | 
					  :description: Configuring authentication for TISBackup web interface
 | 
				
			||||||
 | 
					  :keywords: Documentation, TISBackup, authentication, security, OAuth, Flask-Login
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Authentication Configuration
 | 
				
			||||||
 | 
					============================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. _authentication_configuration:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					TISBackup provides a pluggable authentication system for the Flask web interface,
 | 
				
			||||||
 | 
					supporting multiple authentication methods to suit different deployment scenarios.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Overview
 | 
				
			||||||
 | 
					--------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The authentication system supports three authentication providers:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* **Basic Authentication** - Simple HTTP Basic Auth (default)
 | 
				
			||||||
 | 
					* **Flask-Login** - Session-based authentication with user management
 | 
				
			||||||
 | 
					* **OAuth2** - Integration with external identity providers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					By default, TISBackup uses Basic Authentication. You can configure the authentication
 | 
				
			||||||
 | 
					method in the :file:`/etc/tis/tisbackup_gui.ini` configuration file.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Basic Authentication
 | 
				
			||||||
 | 
					--------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					HTTP Basic Authentication is the simplest method and is enabled by default.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Configuration via Environment Variables
 | 
				
			||||||
 | 
					+++++++++++++++++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Set the following environment variables:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export TISBACKUP_AUTH_USERNAME="admin"
 | 
				
			||||||
 | 
					  export TISBACKUP_AUTH_PASSWORD="your-secure-password"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Configuration via INI File
 | 
				
			||||||
 | 
					++++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Create or edit :file:`/etc/tis/tisbackup_gui.ini`:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=basic
 | 
				
			||||||
 | 
					  username=admin
 | 
				
			||||||
 | 
					  password=your-password
 | 
				
			||||||
 | 
					  use_bcrypt=False
 | 
				
			||||||
 | 
					  realm=TISBackup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Using Bcrypt Password Hashes (Recommended)
 | 
				
			||||||
 | 
					+++++++++++++++++++++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For improved security, use bcrypt-hashed passwords:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Install bcrypt support:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   .. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     uv pip install bcrypt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. Generate a password hash:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   .. code-block:: python
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     import bcrypt
 | 
				
			||||||
 | 
					     password = b"your-password"
 | 
				
			||||||
 | 
					     hash = bcrypt.hashpw(password, bcrypt.gensalt())
 | 
				
			||||||
 | 
					     print(hash.decode())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. Update configuration:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   .. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     [authentication]
 | 
				
			||||||
 | 
					     type=basic
 | 
				
			||||||
 | 
					     username=admin
 | 
				
			||||||
 | 
					     password_hash=$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5eSZL9fJQp.Ym
 | 
				
			||||||
 | 
					     use_bcrypt=True
 | 
				
			||||||
 | 
					     realm=TISBackup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Flask-Login Authentication
 | 
				
			||||||
 | 
					---------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Session-based authentication with user management and login pages.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Installation
 | 
				
			||||||
 | 
					++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Install Flask-Login support:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  uv pip install flask-login bcrypt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Configuration
 | 
				
			||||||
 | 
					+++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Create :file:`/etc/tis/tisbackup_gui.ini`:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=flask-login
 | 
				
			||||||
 | 
					  user_file=/etc/tis/tisbackup_users.txt
 | 
				
			||||||
 | 
					  secret_key=<generate-random-secret-key>
 | 
				
			||||||
 | 
					  session_timeout=3600
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Generate a secret key:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  python3 -c "import secrets; print(secrets.token_hex(32))"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					User File Format
 | 
				
			||||||
 | 
					++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Create a user file at :file:`/etc/tis/tisbackup_users.txt`:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: text
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5eSZL9fJQp.Ym
 | 
				
			||||||
 | 
					  user1:$2b$12$KPOvd2wqZWVIxje1MIBlDPZy7UuyNRKriQ9/MfxZ6fTaM9gKRq.Wm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Each line is: ``username:bcrypt_password_hash``
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Managing Users
 | 
				
			||||||
 | 
					++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Add a new user:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: python
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  import bcrypt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  username = "newuser"
 | 
				
			||||||
 | 
					  password = b"secure-password"
 | 
				
			||||||
 | 
					  hash = bcrypt.hashpw(password, bcrypt.gensalt()).decode()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  with open("/etc/tis/tisbackup_users.txt", "a") as f:
 | 
				
			||||||
 | 
					      f.write(f"{username}:{hash}\n")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ensure proper permissions:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  chmod 600 /etc/tis/tisbackup_users.txt
 | 
				
			||||||
 | 
					  chown root:root /etc/tis/tisbackup_users.txt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					OAuth2 Authentication
 | 
				
			||||||
 | 
					---------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Integrate with external OAuth2 identity providers like Google, GitHub, or GitLab.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Installation
 | 
				
			||||||
 | 
					++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Install OAuth support:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  uv pip install authlib requests
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Google OAuth
 | 
				
			||||||
 | 
					++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Create OAuth credentials in Google Cloud Console
 | 
				
			||||||
 | 
					2. Configure TISBackup:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=oauth
 | 
				
			||||||
 | 
					  provider=google
 | 
				
			||||||
 | 
					  client_id=<your-client-id>.apps.googleusercontent.com
 | 
				
			||||||
 | 
					  client_secret=<your-client-secret>
 | 
				
			||||||
 | 
					  redirect_uri=https://backup.example.com/callback
 | 
				
			||||||
 | 
					  allowed_domains=example.com
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					GitHub OAuth
 | 
				
			||||||
 | 
					++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Create OAuth App in GitHub Settings
 | 
				
			||||||
 | 
					2. Configure TISBackup:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=oauth
 | 
				
			||||||
 | 
					  provider=github
 | 
				
			||||||
 | 
					  client_id=<your-client-id>
 | 
				
			||||||
 | 
					  client_secret=<your-client-secret>
 | 
				
			||||||
 | 
					  redirect_uri=https://backup.example.com/callback
 | 
				
			||||||
 | 
					  allowed_users=user1,user2,user3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					GitLab OAuth
 | 
				
			||||||
 | 
					++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Create OAuth application in GitLab
 | 
				
			||||||
 | 
					2. Configure TISBackup:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=oauth
 | 
				
			||||||
 | 
					  provider=gitlab
 | 
				
			||||||
 | 
					  client_id=<your-client-id>
 | 
				
			||||||
 | 
					  client_secret=<your-client-secret>
 | 
				
			||||||
 | 
					  redirect_uri=https://backup.example.com/callback
 | 
				
			||||||
 | 
					  gitlab_url=https://gitlab.example.com
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Generic OAuth Provider
 | 
				
			||||||
 | 
					++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For custom OAuth providers:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=oauth
 | 
				
			||||||
 | 
					  provider=generic
 | 
				
			||||||
 | 
					  client_id=<your-client-id>
 | 
				
			||||||
 | 
					  client_secret=<your-client-secret>
 | 
				
			||||||
 | 
					  redirect_uri=https://backup.example.com/callback
 | 
				
			||||||
 | 
					  authorize_url=https://provider.example.com/oauth/authorize
 | 
				
			||||||
 | 
					  token_url=https://provider.example.com/oauth/token
 | 
				
			||||||
 | 
					  userinfo_url=https://provider.example.com/oauth/userinfo
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Advanced Configuration
 | 
				
			||||||
 | 
					----------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Multiple Authentication Methods
 | 
				
			||||||
 | 
					++++++++++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					You can only use one authentication method at a time. To switch methods,
 | 
				
			||||||
 | 
					update the ``type`` parameter in the configuration file and restart
 | 
				
			||||||
 | 
					the TISBackup GUI service.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Disabling Authentication (Not Recommended)
 | 
				
			||||||
 | 
					++++++++++++++++++++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. warning::
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Disabling authentication is **not recommended** for production environments.
 | 
				
			||||||
 | 
					  Only use this for testing or when the web interface is protected by other means
 | 
				
			||||||
 | 
					  (e.g., VPN, firewall rules).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					To disable authentication:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Custom Realm
 | 
				
			||||||
 | 
					++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For Basic Authentication, customize the authentication realm:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=basic
 | 
				
			||||||
 | 
					  realm=My Company Backup System
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Session Timeout
 | 
				
			||||||
 | 
					+++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For Flask-Login and OAuth, configure session timeout (in seconds):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=flask-login
 | 
				
			||||||
 | 
					  session_timeout=7200  # 2 hours
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Troubleshooting
 | 
				
			||||||
 | 
					---------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Authentication Not Working
 | 
				
			||||||
 | 
					++++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Check the logs for authentication errors:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  journalctl -u tisbackup_gui -n 100
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Verify configuration file syntax:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  python3 -c "from configparser import ConfigParser; cp = ConfigParser(); cp.read('/etc/tis/tisbackup_gui.ini'); print('OK')"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Random Password Generated
 | 
				
			||||||
 | 
					++++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					If you see a warning about a generated password in the logs:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: text
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  WARNING: Generated temporary password for 'admin': abc123xyz
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This means no password was configured. Set ``TISBACKUP_AUTH_PASSWORD`` environment
 | 
				
			||||||
 | 
					variable or add an ``[authentication]`` section to the configuration file.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					OAuth Callback Error
 | 
				
			||||||
 | 
					++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ensure the redirect URI in your OAuth provider configuration **exactly matches**
 | 
				
			||||||
 | 
					the ``redirect_uri`` parameter in the TISBackup configuration.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The redirect URI should be: ``https://your-domain.com/callback``
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					User File Not Found
 | 
				
			||||||
 | 
					+++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For Flask-Login authentication, ensure the user file exists and has proper permissions:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ls -l /etc/tis/tisbackup_users.txt
 | 
				
			||||||
 | 
					  # Should show: -rw------- 1 root root ...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Security Recommendations
 | 
				
			||||||
 | 
					------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Use HTTPS**: Always use HTTPS in production (configure via reverse proxy)
 | 
				
			||||||
 | 
					2. **Strong Passwords**: Use long, random passwords or password hashes
 | 
				
			||||||
 | 
					3. **Restrict Access**: Use firewall rules to limit access to trusted networks
 | 
				
			||||||
 | 
					4. **Regular Updates**: Keep authentication dependencies updated
 | 
				
			||||||
 | 
					5. **Monitor Logs**: Regularly check logs for failed authentication attempts
 | 
				
			||||||
 | 
					6. **Session Security**: Use short session timeouts for sensitive environments
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For more security best practices, see the **Security Best Practices** section of the documentation.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Migration Guide
 | 
				
			||||||
 | 
					---------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					From No Authentication
 | 
				
			||||||
 | 
					++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					If upgrading from a version without authentication:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Add authentication configuration as described above
 | 
				
			||||||
 | 
					2. Restart the TISBackup GUI service
 | 
				
			||||||
 | 
					3. Update any automated tools to include authentication credentials
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					From Basic to OAuth
 | 
				
			||||||
 | 
					+++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Set up OAuth provider configuration
 | 
				
			||||||
 | 
					2. Update ``type=oauth`` in configuration file
 | 
				
			||||||
 | 
					3. Install required dependencies: ``uv pip install authlib requests``
 | 
				
			||||||
 | 
					4. Restart the service
 | 
				
			||||||
 | 
					5. Test login with OAuth provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Additional Resources
 | 
				
			||||||
 | 
					--------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For comprehensive authentication setup examples and troubleshooting,
 | 
				
			||||||
 | 
					see the :file:`AUTHENTICATION.md` file in the TISBackup repository root.
 | 
				
			||||||
@ -35,6 +35,7 @@ extensions = [
 | 
				
			|||||||
    "sphinx.ext.todo",
 | 
					    "sphinx.ext.todo",
 | 
				
			||||||
    "sphinx.ext.viewcode",
 | 
					    "sphinx.ext.viewcode",
 | 
				
			||||||
    "sphinx.ext.githubpages",
 | 
					    "sphinx.ext.githubpages",
 | 
				
			||||||
 | 
					    "sphinx_tabs.tabs",
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Add any paths that contain templates here, relative to this directory.
 | 
					# Add any paths that contain templates here, relative to this directory.
 | 
				
			||||||
@ -124,22 +125,9 @@ todo_include_todos = True
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# -- Options for HTML output ----------------------------------------------
 | 
					# -- Options for HTML output ----------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
try:
 | 
					html_theme = "alabaster"
 | 
				
			||||||
    import sphinx_rtd_theme
 | 
					html_theme_path = []
 | 
				
			||||||
 | 
					html_favicon = "_static/favicon.ico"
 | 
				
			||||||
    html_theme = "sphinx_rtd_theme"
 | 
					 | 
				
			||||||
    html_favicon = "_static/favicon.ico"
 | 
					 | 
				
			||||||
    html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
 | 
					 | 
				
			||||||
    html_context = {
 | 
					 | 
				
			||||||
        "css_files": [
 | 
					 | 
				
			||||||
            "_static/css/custom.css",  # overrides for wide tables in RTD theme
 | 
					 | 
				
			||||||
            "_static/css/ribbon.css",
 | 
					 | 
				
			||||||
            "_static/theme_overrides.css",  # override wide tables in RTD theme
 | 
					 | 
				
			||||||
        ],
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
except ImportError as e:  # noqa : F841
 | 
					 | 
				
			||||||
    html_theme = "alabaster"
 | 
					 | 
				
			||||||
    html_theme_path = []
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# The theme to use for HTML and HTML Help pages.  See the documentation for
 | 
					# The theme to use for HTML and HTML Help pages.  See the documentation for
 | 
				
			||||||
@ -381,7 +369,9 @@ texinfo_documents = [
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Example configuration for intersphinx: refer to the Python standard library.
 | 
					# Example configuration for intersphinx: refer to the Python standard library.
 | 
				
			||||||
intersphinx_mapping = {"https://docs.python.org/": None}
 | 
					intersphinx_mapping = {
 | 
				
			||||||
 | 
					    "python": ("https://docs.python.org/3", None),
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# -- Options for Epub output ----------------------------------------------
 | 
					# -- Options for Epub output ----------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -82,7 +82,7 @@ Backing up a MySQL database
 | 
				
			|||||||
  [srvintranet_mysql_mediawiki]
 | 
					  [srvintranet_mysql_mediawiki]
 | 
				
			||||||
  type=mysql+ssh
 | 
					  type=mysql+ssh
 | 
				
			||||||
  server_name=srvintranet
 | 
					  server_name=srvintranet
 | 
				
			||||||
  private_key=/root/.ssh/id_dsa
 | 
					  private_key=/root/.ssh/id_ed25519
 | 
				
			||||||
  db_name=mediawiki
 | 
					  db_name=mediawiki
 | 
				
			||||||
  db_user=user
 | 
					  db_user=user
 | 
				
			||||||
  db_passwd=password
 | 
					  db_passwd=password
 | 
				
			||||||
@ -141,7 +141,7 @@ Backing up a file server
 | 
				
			|||||||
  type=rsync+ssh
 | 
					  type=rsync+ssh
 | 
				
			||||||
  server_name=srvfiles
 | 
					  server_name=srvfiles
 | 
				
			||||||
  remote_dir=/home
 | 
					  remote_dir=/home
 | 
				
			||||||
  private_key=/root/.ssh/id_dsa
 | 
					  private_key=/root/.ssh/id_ed25519
 | 
				
			||||||
  exclude_list=".mozilla",".thunderbird",".x2go","*.avi"
 | 
					  exclude_list=".mozilla",".thunderbird",".x2go","*.avi"
 | 
				
			||||||
  bwlimit = 100
 | 
					  bwlimit = 100
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -92,6 +92,13 @@ would have been difficult to develop as an overlay of the existing one:
 | 
				
			|||||||
  configuring_tisbackup.rst
 | 
					  configuring_tisbackup.rst
 | 
				
			||||||
  using_tisbackup.rst
 | 
					  using_tisbackup.rst
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. toctree::
 | 
				
			||||||
 | 
					  :maxdepth: 2
 | 
				
			||||||
 | 
					  :caption: Security & Authentication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  security.rst
 | 
				
			||||||
 | 
					  authentication.rst
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.. toctree::
 | 
					.. toctree::
 | 
				
			||||||
  :maxdepth: 1
 | 
					  :maxdepth: 1
 | 
				
			||||||
  :caption: Appendix
 | 
					  :caption: Appendix
 | 
				
			||||||
 | 
				
			|||||||
@ -251,14 +251,24 @@ Launching the backup scheduled task
 | 
				
			|||||||
Generating the public and private certificates
 | 
					Generating the public and private certificates
 | 
				
			||||||
++++++++++++++++++++++++++++++++++++++++++++++
 | 
					++++++++++++++++++++++++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* as root:
 | 
					* as root, generate an Ed25519 SSH key (modern and secure algorithm):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .. code-block:: bash
 | 
					  .. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ssh-keygen -t rsa -b 2048
 | 
					    ssh-keygen -t ed25519 -C "tisbackup@$(hostname)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* press :kbd:`Enter` for each one of the steps;
 | 
					* press :kbd:`Enter` for each one of the steps;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. note::
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  TISBackup supports Ed25519, ECDSA, and RSA key algorithms (in order of preference).
 | 
				
			||||||
 | 
					  DSA keys are no longer supported for security reasons. If you need RSA for compatibility,
 | 
				
			||||||
 | 
					  use at least 4096 bits:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ssh-keygen -t rsa -b 4096 -C "tisbackup@$(hostname)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
|clap| You may now go on to the next step
 | 
					|clap| You may now go on to the next step
 | 
				
			||||||
and :ref:`configure the backup jobs for your TISBackup<configuring_backup_jobs>`.
 | 
					and :ref:`configure the backup jobs for your TISBackup<configuring_backup_jobs>`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										288
									
								
								docs-sphinx-rst/source/security.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										288
									
								
								docs-sphinx-rst/source/security.rst
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,288 @@
 | 
				
			|||||||
 | 
					.. Reminder for header structure:
 | 
				
			||||||
 | 
					  Level 1: ====================
 | 
				
			||||||
 | 
					  Level 2: --------------------
 | 
				
			||||||
 | 
					  Level 3: ++++++++++++++++++++
 | 
				
			||||||
 | 
					  Level 4: """"""""""""""""""""
 | 
				
			||||||
 | 
					  Level 5: ^^^^^^^^^^^^^^^^^^^^
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. meta::
 | 
				
			||||||
 | 
					  :description: Security best practices for TISBackup
 | 
				
			||||||
 | 
					  :keywords: Documentation, TISBackup, security, best practices, authentication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Security Best Practices
 | 
				
			||||||
 | 
					=======================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. _security_best_practices:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					TISBackup has been designed with security in mind. This section outlines
 | 
				
			||||||
 | 
					the security features and best practices for deploying and maintaining
 | 
				
			||||||
 | 
					a secure backup infrastructure.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					SSH Key Algorithm Support
 | 
				
			||||||
 | 
					--------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Modern SSH Key Algorithms
 | 
				
			||||||
 | 
					+++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					TISBackup supports modern SSH key algorithms with the following priority:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Ed25519** (recommended) - Modern, fast, and secure
 | 
				
			||||||
 | 
					2. **ECDSA** - Elliptic curve cryptography
 | 
				
			||||||
 | 
					3. **RSA** - Traditional algorithm (use 4096 bits minimum)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. warning::
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DSA keys are **no longer supported** due to known security vulnerabilities.
 | 
				
			||||||
 | 
					  If you are using DSA keys, you must migrate to Ed25519, ECDSA, or RSA.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Generating Secure SSH Keys
 | 
				
			||||||
 | 
					+++++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For new installations, generate an Ed25519 key:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ssh-keygen -t ed25519 -C "tisbackup@$(hostname)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For compatibility with older systems that don't support Ed25519, use RSA with 4096 bits:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ssh-keygen -t rsa -b 4096 -C "tisbackup@$(hostname)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Migrating from DSA Keys
 | 
				
			||||||
 | 
					++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					If you have existing backup configurations using DSA keys:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Generate a new Ed25519 key on the backup server
 | 
				
			||||||
 | 
					2. Copy the new public key to all backup clients
 | 
				
			||||||
 | 
					3. Update the ``private_key`` parameter in all backup sections
 | 
				
			||||||
 | 
					4. Test the backups to ensure they work with the new key
 | 
				
			||||||
 | 
					5. Remove the old DSA keys from both server and clients
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Flask Web Interface Security
 | 
				
			||||||
 | 
					-----------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Authentication
 | 
				
			||||||
 | 
					++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The Flask web interface now requires authentication by default.
 | 
				
			||||||
 | 
					TISBackup supports multiple authentication methods:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Basic Authentication (Default)
 | 
				
			||||||
 | 
					"""""""""""""""""""""""""""""""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					By default, TISBackup uses HTTP Basic Authentication. Configure it via
 | 
				
			||||||
 | 
					environment variables or the configuration file.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Environment variables:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export TISBACKUP_AUTH_USERNAME="admin"
 | 
				
			||||||
 | 
					  export TISBACKUP_AUTH_PASSWORD="your-secure-password"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Configuration file** (:file:`/etc/tis/tisbackup_gui.ini`):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=basic
 | 
				
			||||||
 | 
					  username=admin
 | 
				
			||||||
 | 
					  # Bcrypt hash of password (recommended)
 | 
				
			||||||
 | 
					  password_hash=$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5eSZL9fJQp.Ym
 | 
				
			||||||
 | 
					  use_bcrypt=True
 | 
				
			||||||
 | 
					  realm=TISBackup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. warning::
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  If no password is configured, TISBackup will generate a random password
 | 
				
			||||||
 | 
					  and display it in the logs. This is not suitable for production use.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Session-Based Authentication (Flask-Login)
 | 
				
			||||||
 | 
					"""""""""""""""""""""""""""""""""""""""""""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For more advanced deployments, you can use Flask-Login with a user file:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=flask-login
 | 
				
			||||||
 | 
					  user_file=/etc/tis/tisbackup_users.txt
 | 
				
			||||||
 | 
					  secret_key=<random-secret-key>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					OAuth2 Authentication
 | 
				
			||||||
 | 
					""""""""""""""""""""""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For enterprise deployments, OAuth2 is supported with providers like Google,
 | 
				
			||||||
 | 
					GitHub, and GitLab:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [authentication]
 | 
				
			||||||
 | 
					  type=oauth
 | 
				
			||||||
 | 
					  provider=google
 | 
				
			||||||
 | 
					  client_id=<your-client-id>
 | 
				
			||||||
 | 
					  client_secret=<your-client-secret>
 | 
				
			||||||
 | 
					  redirect_uri=http://backup.example.com:8080/callback
 | 
				
			||||||
 | 
					  allowed_domains=example.com
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					See :file:`AUTHENTICATION.md` in the repository root for detailed
 | 
				
			||||||
 | 
					authentication configuration.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Secret Key Configuration
 | 
				
			||||||
 | 
					+++++++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The Flask application requires a secret key for session security.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Never use the default hardcoded key in production!**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Configure via environment variable:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export TISBACKUP_SECRET_KEY="your-random-secret-key-here"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Or in :file:`/etc/tis/tisbackup_gui.ini`:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [global]
 | 
				
			||||||
 | 
					  secret_key=your-random-secret-key-here
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Generate a secure random key:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  python3 -c "import secrets; print(secrets.token_hex(32))"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					SSL/TLS Configuration
 | 
				
			||||||
 | 
					+++++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For production deployments, always use HTTPS. Place the Flask application
 | 
				
			||||||
 | 
					behind a reverse proxy like Nginx or Apache:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Nginx example:**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: nginx
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  server {
 | 
				
			||||||
 | 
					      listen 443 ssl http2;
 | 
				
			||||||
 | 
					      server_name backup.example.com;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      ssl_certificate /etc/ssl/certs/backup.crt;
 | 
				
			||||||
 | 
					      ssl_certificate_key /etc/ssl/private/backup.key;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      location / {
 | 
				
			||||||
 | 
					          proxy_pass http://127.0.0.1:8080;
 | 
				
			||||||
 | 
					          proxy_set_header Host $host;
 | 
				
			||||||
 | 
					          proxy_set_header X-Real-IP $remote_addr;
 | 
				
			||||||
 | 
					          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
				
			||||||
 | 
					          proxy_set_header X-Forwarded-Proto $scheme;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Database and Backup Security
 | 
				
			||||||
 | 
					-----------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					File Permissions
 | 
				
			||||||
 | 
					++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ensure proper file permissions on sensitive files:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # Configuration files
 | 
				
			||||||
 | 
					  chmod 600 /etc/tis/tisbackup-config.ini
 | 
				
			||||||
 | 
					  chmod 600 /etc/tis/tisbackup_gui.ini
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # SSH keys
 | 
				
			||||||
 | 
					  chmod 600 /root/.ssh/id_ed25519
 | 
				
			||||||
 | 
					  chmod 644 /root/.ssh/id_ed25519.pub
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # Password files (for XenServer, etc.)
 | 
				
			||||||
 | 
					  chmod 600 /root/xen_passwd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # Backup directory
 | 
				
			||||||
 | 
					  chown -R root:root /backup/data
 | 
				
			||||||
 | 
					  chmod 750 /backup/data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Credential Storage
 | 
				
			||||||
 | 
					++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					For database credentials and other secrets:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Use strong, unique passwords for each service
 | 
				
			||||||
 | 
					* Store credentials in configuration files with restricted permissions
 | 
				
			||||||
 | 
					* Consider using a secrets management system for sensitive deployments
 | 
				
			||||||
 | 
					* Rotate credentials regularly
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Network Security
 | 
				
			||||||
 | 
					++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Restrict SSH access to the backup server IP address
 | 
				
			||||||
 | 
					* Use firewall rules to limit access to the web interface
 | 
				
			||||||
 | 
					* Consider VPN access for remote backup management
 | 
				
			||||||
 | 
					* Enable fail2ban or similar tools to prevent brute-force attacks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Security Monitoring
 | 
				
			||||||
 | 
					-------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Log Monitoring
 | 
				
			||||||
 | 
					++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Regularly review TISBackup logs for:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Failed authentication attempts
 | 
				
			||||||
 | 
					* Backup failures or timeouts
 | 
				
			||||||
 | 
					* Unusual activity patterns
 | 
				
			||||||
 | 
					* SSH connection errors
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # View recent backup logs
 | 
				
			||||||
 | 
					  journalctl -u tisbackup_gui -n 100
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # Monitor for authentication failures
 | 
				
			||||||
 | 
					  grep "authentication failed" /var/log/tisbackup/*.log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Backup Verification
 | 
				
			||||||
 | 
					+++++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Regularly test backup restoration
 | 
				
			||||||
 | 
					* Verify backup integrity using checksums
 | 
				
			||||||
 | 
					* Monitor backup sizes for unexpected changes
 | 
				
			||||||
 | 
					* Set up Nagios checks for backup freshness
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Security Updates
 | 
				
			||||||
 | 
					++++++++++++++++
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Keep TISBackup updated to the latest version
 | 
				
			||||||
 | 
					* Apply security patches to the host operating system
 | 
				
			||||||
 | 
					* Update Python dependencies regularly:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. code-block:: bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  uv sync --upgrade
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Additional Security Recommendations
 | 
				
			||||||
 | 
					------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Principle of Least Privilege**: Create dedicated service accounts
 | 
				
			||||||
 | 
					   for backups rather than using root when possible
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. **Network Segmentation**: Place the backup server in a dedicated
 | 
				
			||||||
 | 
					   network segment with restricted access
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. **Backup Encryption**: Consider encrypting backups at rest,
 | 
				
			||||||
 | 
					   especially for sensitive data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. **Off-site Storage**: Maintain encrypted off-site backups
 | 
				
			||||||
 | 
					   for disaster recovery
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					5. **Access Auditing**: Maintain logs of who accesses backups
 | 
				
			||||||
 | 
					   and when they are restored
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					6. **Incident Response**: Have a documented procedure for responding
 | 
				
			||||||
 | 
					   to security incidents involving the backup infrastructure
 | 
				
			||||||
							
								
								
									
										323
									
								
								libtisbackup/auth/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								libtisbackup/auth/README.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,323 @@
 | 
				
			|||||||
 | 
					# TISBackup Authentication Module
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Pluggable authentication system for Flask routes.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Multiple providers**: Basic Auth, Flask-Login, OAuth2
 | 
				
			||||||
 | 
					- **Easy integration**: Simple decorator-based protection
 | 
				
			||||||
 | 
					- **Configurable**: INI-based configuration
 | 
				
			||||||
 | 
					- **Secure**: bcrypt password hashing, OAuth integration
 | 
				
			||||||
 | 
					- **Extensible**: Easy to add new providers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Quick Start
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1. Choose Authentication Provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					from libtisbackup.auth import get_auth_provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Get provider from config
 | 
				
			||||||
 | 
					auth = get_auth_provider("basic", {
 | 
				
			||||||
 | 
					    "username": "admin",
 | 
				
			||||||
 | 
					    "password": "$2b$12$...",  # bcrypt hash
 | 
				
			||||||
 | 
					    "use_bcrypt": True
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Initialize with Flask app
 | 
				
			||||||
 | 
					auth.init_app(app)
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2. Protect Routes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					@app.route("/")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
 | 
					def index():
 | 
				
			||||||
 | 
					    user = auth.get_current_user()
 | 
				
			||||||
 | 
					    return f"Hello {user['username']}"
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Providers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Base Provider (No Auth)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					auth = get_auth_provider("none", {})
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- No authentication required
 | 
				
			||||||
 | 
					- All routes publicly accessible
 | 
				
			||||||
 | 
					- Useful for development/testing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Basic Auth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					auth = get_auth_provider("basic", {
 | 
				
			||||||
 | 
					    "username": "admin",
 | 
				
			||||||
 | 
					    "password": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6",
 | 
				
			||||||
 | 
					    "use_bcrypt": True,
 | 
				
			||||||
 | 
					    "realm": "TISBackup"
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Required dependencies:**
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					uv sync --extra auth-basic
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Features:**
 | 
				
			||||||
 | 
					- HTTP Basic Authentication
 | 
				
			||||||
 | 
					- bcrypt password hashing
 | 
				
			||||||
 | 
					- Custom realm support
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Flask-Login
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					auth = get_auth_provider("flask-login", {
 | 
				
			||||||
 | 
					    "users_file": "/etc/tis/users.txt",
 | 
				
			||||||
 | 
					    "use_bcrypt": True,
 | 
				
			||||||
 | 
					    "login_view": "login"
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Required dependencies:**
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					uv sync --extra auth-login
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Features:**
 | 
				
			||||||
 | 
					- Session-based authentication
 | 
				
			||||||
 | 
					- Multiple users support
 | 
				
			||||||
 | 
					- Login/logout pages
 | 
				
			||||||
 | 
					- bcrypt password hashing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**User file format** (`users.txt`):
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					username:bcrypt_hash
 | 
				
			||||||
 | 
					admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### OAuth2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					auth = get_auth_provider("oauth", {
 | 
				
			||||||
 | 
					    "provider": "google",  # or "github", "gitlab", "generic"
 | 
				
			||||||
 | 
					    "client_id": "your-client-id",
 | 
				
			||||||
 | 
					    "client_secret": "your-client-secret",
 | 
				
			||||||
 | 
					    "redirect_uri": "http://localhost:8080/oauth/callback",
 | 
				
			||||||
 | 
					    "authorized_domains": ["example.com"],
 | 
				
			||||||
 | 
					    "authorized_users": ["admin@example.com"]
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Required dependencies:**
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					uv sync --extra auth-oauth
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Features:**
 | 
				
			||||||
 | 
					- OAuth2 authentication
 | 
				
			||||||
 | 
					- Google, GitHub, GitLab support
 | 
				
			||||||
 | 
					- Custom OAuth providers
 | 
				
			||||||
 | 
					- Domain/user restrictions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## API Reference
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### AuthProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Base class for all auth providers.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Methods
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `init_app(app)` - Initialize with Flask app
 | 
				
			||||||
 | 
					- `require_auth(f)` - Decorator to protect routes
 | 
				
			||||||
 | 
					- `is_authenticated()` - Check if current request is authenticated
 | 
				
			||||||
 | 
					- `get_current_user()` - Get current user info
 | 
				
			||||||
 | 
					- `handle_unauthorized()` - Handle unauthorized access
 | 
				
			||||||
 | 
					- `logout()` - Logout current user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### BasicAuthProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					HTTP Basic Authentication provider.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Configuration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    "username": str,          # Required
 | 
				
			||||||
 | 
					    "password": str,          # Required (plain or bcrypt hash)
 | 
				
			||||||
 | 
					    "use_bcrypt": bool,       # Default: False
 | 
				
			||||||
 | 
					    "realm": str             # Default: "TISBackup"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### FlaskLoginProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Session-based authentication provider.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Configuration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    "users": dict,            # {username: password_hash} or...
 | 
				
			||||||
 | 
					    "users_file": str,        # Path to users file
 | 
				
			||||||
 | 
					    "use_bcrypt": bool,       # Default: True
 | 
				
			||||||
 | 
					    "login_view": str        # Default: "login"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Methods
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `verify_password(username, password)` - Verify credentials
 | 
				
			||||||
 | 
					- `login_user(username)` - Login user by username
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### OAuthProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					OAuth2 authentication provider.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Configuration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    "provider": str,              # "google", "github", "gitlab", "generic"
 | 
				
			||||||
 | 
					    "client_id": str,             # Required
 | 
				
			||||||
 | 
					    "client_secret": str,         # Required
 | 
				
			||||||
 | 
					    "redirect_uri": str,          # Required
 | 
				
			||||||
 | 
					    "scopes": list,               # Optional
 | 
				
			||||||
 | 
					    "authorized_domains": list,   # Optional
 | 
				
			||||||
 | 
					    "authorized_users": list,     # Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # For generic provider:
 | 
				
			||||||
 | 
					    "authorization_endpoint": str,
 | 
				
			||||||
 | 
					    "token_endpoint": str,
 | 
				
			||||||
 | 
					    "userinfo_endpoint": str
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Methods
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `is_user_authorized(user_info)` - Check if user is authorized
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Integration Example
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					See [example_integration.py](example_integration.py) for a complete example.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Minimal Example
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					from flask import Flask
 | 
				
			||||||
 | 
					from libtisbackup.auth import get_auth_provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app = Flask(__name__)
 | 
				
			||||||
 | 
					app.secret_key = "your-secret-key"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Initialize auth
 | 
				
			||||||
 | 
					auth = get_auth_provider("basic", {
 | 
				
			||||||
 | 
					    "username": "admin",
 | 
				
			||||||
 | 
					    "password": "changeme",
 | 
				
			||||||
 | 
					    "use_bcrypt": False
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					auth.init_app(app)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Protected route
 | 
				
			||||||
 | 
					@app.route("/")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
 | 
					def index():
 | 
				
			||||||
 | 
					    return "Protected content"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Public route
 | 
				
			||||||
 | 
					@app.route("/health")
 | 
				
			||||||
 | 
					def health():
 | 
				
			||||||
 | 
					    return "OK"
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### With Flask-Login
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					auth = get_auth_provider("flask-login", {
 | 
				
			||||||
 | 
					    "users": {
 | 
				
			||||||
 | 
					        "admin": "$2b$12$..."
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "use_bcrypt": True
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					auth.init_app(app)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/login", methods=["POST"])
 | 
				
			||||||
 | 
					def login():
 | 
				
			||||||
 | 
					    username = request.form["username"]
 | 
				
			||||||
 | 
					    password = request.form["password"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if auth.verify_password(username, password):
 | 
				
			||||||
 | 
					        auth.login_user(username)
 | 
				
			||||||
 | 
					        return redirect("/")
 | 
				
			||||||
 | 
					    return "Invalid credentials", 401
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
 | 
					def index():
 | 
				
			||||||
 | 
					    user = auth.get_current_user()
 | 
				
			||||||
 | 
					    return f"Hello {user['username']}"
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### With OAuth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					auth = get_auth_provider("oauth", {
 | 
				
			||||||
 | 
					    "provider": "google",
 | 
				
			||||||
 | 
					    "client_id": "...",
 | 
				
			||||||
 | 
					    "client_secret": "...",
 | 
				
			||||||
 | 
					    "redirect_uri": "http://localhost:8080/oauth/callback",
 | 
				
			||||||
 | 
					    "authorized_domains": ["example.com"]
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					auth.init_app(app)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/oauth/login")
 | 
				
			||||||
 | 
					def oauth_login():
 | 
				
			||||||
 | 
					    return auth.oauth_client.authorize_redirect(
 | 
				
			||||||
 | 
					        url_for("oauth_callback", _external=True)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route("/oauth/callback")
 | 
				
			||||||
 | 
					def oauth_callback():
 | 
				
			||||||
 | 
					    token = auth.oauth_client.authorize_access_token()
 | 
				
			||||||
 | 
					    user_info = auth.oauth_client.get(auth.userinfo_endpoint).json()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if auth.is_user_authorized(user_info):
 | 
				
			||||||
 | 
					        session["oauth_user"] = user_info
 | 
				
			||||||
 | 
					        return redirect("/")
 | 
				
			||||||
 | 
					    return "Unauthorized", 403
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Security Considerations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **Always use HTTPS in production** - Especially for Basic Auth
 | 
				
			||||||
 | 
					2. **Use bcrypt for passwords** - Never store plain text passwords
 | 
				
			||||||
 | 
					3. **Rotate credentials regularly** - Change passwords and OAuth secrets
 | 
				
			||||||
 | 
					4. **Restrict OAuth access** - Use `authorized_domains` or `authorized_users`
 | 
				
			||||||
 | 
					5. **Set strong Flask secret_key** - Use `secrets.token_hex(32)`
 | 
				
			||||||
 | 
					6. **Protect config files** - `chmod 600` for files with credentials
 | 
				
			||||||
 | 
					7. **Use environment variables** - For sensitive configuration values
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Testing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					from libtisbackup.auth import get_auth_provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestBasicAuth(unittest.TestCase):
 | 
				
			||||||
 | 
					    def test_authentication(self):
 | 
				
			||||||
 | 
					        auth = get_auth_provider("basic", {
 | 
				
			||||||
 | 
					            "username": "admin",
 | 
				
			||||||
 | 
					            "password": "test",
 | 
				
			||||||
 | 
					            "use_bcrypt": False
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Mock request with credentials
 | 
				
			||||||
 | 
					        # Test authentication logic
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## License
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					GPL v3.0 - Same as TISBackup
 | 
				
			||||||
							
								
								
									
										52
									
								
								libtisbackup/auth/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								libtisbackup/auth/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,52 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/python3
 | 
				
			||||||
 | 
					# -*- coding: utf-8 -*-
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					TISBackup Authentication Module
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Provides pluggable authentication providers for Flask routes.
 | 
				
			||||||
 | 
					Supports: Basic Auth, Flask-Login, OAuth2
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .base import AuthProvider
 | 
				
			||||||
 | 
					from .basic_auth import BasicAuthProvider
 | 
				
			||||||
 | 
					from .flask_login_auth import FlaskLoginProvider
 | 
				
			||||||
 | 
					from .oauth_auth import OAuthProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = [
 | 
				
			||||||
 | 
					    "AuthProvider",
 | 
				
			||||||
 | 
					    "BasicAuthProvider",
 | 
				
			||||||
 | 
					    "FlaskLoginProvider",
 | 
				
			||||||
 | 
					    "OAuthProvider",
 | 
				
			||||||
 | 
					    "get_auth_provider",
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_auth_provider(auth_type, config=None):
 | 
				
			||||||
 | 
					    """Factory function to get authentication provider.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Args:
 | 
				
			||||||
 | 
					        auth_type: Type of auth ('basic', 'flask-login', 'oauth', or 'none')
 | 
				
			||||||
 | 
					        config: Configuration dict for the provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Returns:
 | 
				
			||||||
 | 
					        AuthProvider instance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Raises:
 | 
				
			||||||
 | 
					        ValueError: If auth_type is not supported
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    providers = {
 | 
				
			||||||
 | 
					        "none": AuthProvider,
 | 
				
			||||||
 | 
					        "basic": BasicAuthProvider,
 | 
				
			||||||
 | 
					        "flask-login": FlaskLoginProvider,
 | 
				
			||||||
 | 
					        "oauth": OAuthProvider,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    auth_type = auth_type.lower()
 | 
				
			||||||
 | 
					    if auth_type not in providers:
 | 
				
			||||||
 | 
					        raise ValueError(
 | 
				
			||||||
 | 
					            f"Unsupported auth type: {auth_type}. "
 | 
				
			||||||
 | 
					            f"Supported types: {', '.join(providers.keys())}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    provider_class = providers[auth_type]
 | 
				
			||||||
 | 
					    return provider_class(config or {})
 | 
				
			||||||
							
								
								
									
										74
									
								
								libtisbackup/auth/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								libtisbackup/auth/base.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,74 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/python3
 | 
				
			||||||
 | 
					# -*- coding: utf-8 -*-
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Base authentication provider interface
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from abc import ABC, abstractmethod
 | 
				
			||||||
 | 
					from functools import wraps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AuthProvider(ABC):
 | 
				
			||||||
 | 
					    """Base class for authentication providers."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, config):
 | 
				
			||||||
 | 
					        """Initialize the auth provider.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Args:
 | 
				
			||||||
 | 
					            config: Dict with provider-specific configuration
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.config = config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def init_app(self, app):
 | 
				
			||||||
 | 
					        """Initialize the provider with Flask app.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Args:
 | 
				
			||||||
 | 
					            app: Flask application instance
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def require_auth(self, f):
 | 
				
			||||||
 | 
					        """Decorator to require authentication for a route.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Args:
 | 
				
			||||||
 | 
					            f: Flask route function
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Returns:
 | 
				
			||||||
 | 
					            Decorated function
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        @wraps(f)
 | 
				
			||||||
 | 
					        def decorated_function(*args, **kwargs):
 | 
				
			||||||
 | 
					            if not self.is_authenticated():
 | 
				
			||||||
 | 
					                return self.handle_unauthorized()
 | 
				
			||||||
 | 
					            return f(*args, **kwargs)
 | 
				
			||||||
 | 
					        return decorated_function
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_authenticated(self):
 | 
				
			||||||
 | 
					        """Check if current request is authenticated.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Returns:
 | 
				
			||||||
 | 
					            bool: True if authenticated, False otherwise
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # Default: no authentication required
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_unauthorized(self):
 | 
				
			||||||
 | 
					        """Handle unauthorized access.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Returns:
 | 
				
			||||||
 | 
					            Flask response for unauthorized access
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from flask import jsonify
 | 
				
			||||||
 | 
					        return jsonify({"error": "Unauthorized"}), 401
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_current_user(self):
 | 
				
			||||||
 | 
					        """Get current authenticated user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Returns:
 | 
				
			||||||
 | 
					            User object or dict, or None if not authenticated
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def logout(self):
 | 
				
			||||||
 | 
					        """Logout current user."""
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
							
								
								
									
										78
									
								
								libtisbackup/auth/basic_auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								libtisbackup/auth/basic_auth.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,78 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/python3
 | 
				
			||||||
 | 
					# -*- coding: utf-8 -*-
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					HTTP Basic Authentication provider
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import base64
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from functools import wraps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .base import AuthProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BasicAuthProvider(AuthProvider):
 | 
				
			||||||
 | 
					    """HTTP Basic Authentication provider.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Configuration:
 | 
				
			||||||
 | 
					        username: Required username
 | 
				
			||||||
 | 
					        password: Required password (plain text, or hashed with bcrypt)
 | 
				
			||||||
 | 
					        realm: Authentication realm (default: 'TISBackup')
 | 
				
			||||||
 | 
					        use_bcrypt: If True, password is bcrypt hash (default: False)
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, config):
 | 
				
			||||||
 | 
					        super().__init__(config)
 | 
				
			||||||
 | 
					        self.logger = logging.getLogger("tisbackup.auth")
 | 
				
			||||||
 | 
					        self.username = config.get("username")
 | 
				
			||||||
 | 
					        self.password = config.get("password")
 | 
				
			||||||
 | 
					        self.realm = config.get("realm", "TISBackup")
 | 
				
			||||||
 | 
					        self.use_bcrypt = config.get("use_bcrypt", False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not self.username or not self.password:
 | 
				
			||||||
 | 
					            raise ValueError("BasicAuth requires 'username' and 'password' in config")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.use_bcrypt:
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                import bcrypt
 | 
				
			||||||
 | 
					                self.bcrypt = bcrypt
 | 
				
			||||||
 | 
					            except ImportError:
 | 
				
			||||||
 | 
					                raise ImportError("bcrypt library required for password hashing. Install with: pip install bcrypt")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_authenticated(self):
 | 
				
			||||||
 | 
					        """Check if request has valid Basic Auth credentials."""
 | 
				
			||||||
 | 
					        auth = request.authorization
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not auth:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if auth.username != self.username:
 | 
				
			||||||
 | 
					            self.logger.warning(f"Failed authentication attempt for user: {auth.username}")
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.use_bcrypt:
 | 
				
			||||||
 | 
					            # Compare bcrypt hash
 | 
				
			||||||
 | 
					            password_bytes = auth.password.encode('utf-8')
 | 
				
			||||||
 | 
					            hash_bytes = self.password.encode('utf-8') if isinstance(self.password, str) else self.password
 | 
				
			||||||
 | 
					            return self.bcrypt.checkpw(password_bytes, hash_bytes)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Plain text comparison (not recommended for production)
 | 
				
			||||||
 | 
					            return auth.password == self.password
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_unauthorized(self):
 | 
				
			||||||
 | 
					        """Return 401 with WWW-Authenticate header."""
 | 
				
			||||||
 | 
					        from flask import Response
 | 
				
			||||||
 | 
					        return Response(
 | 
				
			||||||
 | 
					            'Authentication required',
 | 
				
			||||||
 | 
					            401,
 | 
				
			||||||
 | 
					            {'WWW-Authenticate': f'Basic realm="{self.realm}"'}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_current_user(self):
 | 
				
			||||||
 | 
					        """Get current authenticated user."""
 | 
				
			||||||
 | 
					        auth = request.authorization
 | 
				
			||||||
 | 
					        if auth and self.is_authenticated():
 | 
				
			||||||
 | 
					            return {"username": auth.username, "auth_type": "basic"}
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
							
								
								
									
										121
									
								
								libtisbackup/auth/example_integration.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								libtisbackup/auth/example_integration.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,121 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/python3
 | 
				
			||||||
 | 
					# -*- coding: utf-8 -*-
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Example integration of authentication providers with Flask app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This file shows how to integrate the authentication system into tisbackup_gui.py
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import Flask, jsonify, render_template, request, redirect, url_for, session
 | 
				
			||||||
 | 
					from libtisbackup.auth import get_auth_provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Example configuration from tisbackup_gui.ini
 | 
				
			||||||
 | 
					auth_config = {
 | 
				
			||||||
 | 
					    "type": "basic",  # or "flask-login", "oauth", "none"
 | 
				
			||||||
 | 
					    "basic": {
 | 
				
			||||||
 | 
					        "username": "admin",
 | 
				
			||||||
 | 
					        "password": "$2b$12$...",  # bcrypt hash
 | 
				
			||||||
 | 
					        "use_bcrypt": True,
 | 
				
			||||||
 | 
					        "realm": "TISBackup Admin"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "flask-login": {
 | 
				
			||||||
 | 
					        "users_file": "/etc/tis/users.txt",  # username:bcrypt_hash per line
 | 
				
			||||||
 | 
					        "use_bcrypt": True,
 | 
				
			||||||
 | 
					        "login_view": "login"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "oauth": {
 | 
				
			||||||
 | 
					        "provider": "google",
 | 
				
			||||||
 | 
					        "client_id": "your-client-id",
 | 
				
			||||||
 | 
					        "client_secret": "your-client-secret",
 | 
				
			||||||
 | 
					        "redirect_uri": "http://localhost:8080/oauth/callback",
 | 
				
			||||||
 | 
					        "authorized_domains": ["example.com"],
 | 
				
			||||||
 | 
					        "authorized_users": ["admin@example.com"]
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def create_app_with_auth():
 | 
				
			||||||
 | 
					    """Example: Create Flask app with authentication."""
 | 
				
			||||||
 | 
					    app = Flask(__name__)
 | 
				
			||||||
 | 
					    app.secret_key = "your-secret-key"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Initialize authentication provider
 | 
				
			||||||
 | 
					    auth_type = auth_config.get("type", "none")
 | 
				
			||||||
 | 
					    provider_config = auth_config.get(auth_type, {})
 | 
				
			||||||
 | 
					    auth = get_auth_provider(auth_type, provider_config)
 | 
				
			||||||
 | 
					    auth.init_app(app)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Protected routes
 | 
				
			||||||
 | 
					    @app.route("/")
 | 
				
			||||||
 | 
					    @auth.require_auth
 | 
				
			||||||
 | 
					    def index():
 | 
				
			||||||
 | 
					        user = auth.get_current_user()
 | 
				
			||||||
 | 
					        return render_template("index.html", user=user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @app.route("/api/backups")
 | 
				
			||||||
 | 
					    @auth.require_auth
 | 
				
			||||||
 | 
					    def api_backups():
 | 
				
			||||||
 | 
					        return jsonify({"backups": []})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Public routes (no auth required)
 | 
				
			||||||
 | 
					    @app.route("/health")
 | 
				
			||||||
 | 
					    def health():
 | 
				
			||||||
 | 
					        return jsonify({"status": "ok"})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Flask-Login specific routes
 | 
				
			||||||
 | 
					    if auth_type == "flask-login":
 | 
				
			||||||
 | 
					        @app.route("/login", methods=["GET", "POST"])
 | 
				
			||||||
 | 
					        def login():
 | 
				
			||||||
 | 
					            if request.method == "POST":
 | 
				
			||||||
 | 
					                username = request.form.get("username")
 | 
				
			||||||
 | 
					                password = request.form.get("password")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if auth.verify_password(username, password):
 | 
				
			||||||
 | 
					                    auth.login_user(username)
 | 
				
			||||||
 | 
					                    return redirect(url_for("index"))
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    return render_template("login.html", error="Invalid credentials")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return render_template("login.html")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @app.route("/logout")
 | 
				
			||||||
 | 
					        def logout():
 | 
				
			||||||
 | 
					            auth.logout()
 | 
				
			||||||
 | 
					            return redirect(url_for("login"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # OAuth specific routes
 | 
				
			||||||
 | 
					    if auth_type == "oauth":
 | 
				
			||||||
 | 
					        @app.route("/oauth/login")
 | 
				
			||||||
 | 
					        def oauth_login():
 | 
				
			||||||
 | 
					            redirect_uri = url_for("oauth_callback", _external=True)
 | 
				
			||||||
 | 
					            return auth.oauth_client.authorize_redirect(redirect_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @app.route("/oauth/callback")
 | 
				
			||||||
 | 
					        def oauth_callback():
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                token = auth.oauth_client.authorize_access_token()
 | 
				
			||||||
 | 
					                user_info = auth.oauth_client.get(auth.userinfo_endpoint).json()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                # Check authorization
 | 
				
			||||||
 | 
					                if not auth.is_user_authorized(user_info):
 | 
				
			||||||
 | 
					                    return "Unauthorized: Your email/domain is not authorized", 403
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                # Store user in session
 | 
				
			||||||
 | 
					                session["oauth_user"] = user_info
 | 
				
			||||||
 | 
					                session["oauth_token"] = token
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                return redirect(url_for("index"))
 | 
				
			||||||
 | 
					            except Exception as e:
 | 
				
			||||||
 | 
					                return f"OAuth callback error: {e}", 500
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @app.route("/logout")
 | 
				
			||||||
 | 
					        def logout():
 | 
				
			||||||
 | 
					            auth.logout()
 | 
				
			||||||
 | 
					            return redirect(url_for("oauth_login"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    app = create_app_with_auth()
 | 
				
			||||||
 | 
					    app.run(debug=True, port=8080)
 | 
				
			||||||
							
								
								
									
										156
									
								
								libtisbackup/auth/flask_login_auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								libtisbackup/auth/flask_login_auth.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,156 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/python3
 | 
				
			||||||
 | 
					# -*- coding: utf-8 -*-
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Flask-Login authentication provider
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from functools import wraps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import redirect, request, session, url_for
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .base import AuthProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FlaskLoginProvider(AuthProvider):
 | 
				
			||||||
 | 
					    """Flask-Login based authentication provider.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Configuration:
 | 
				
			||||||
 | 
					        users: Dict of {username: password_hash} or path to user file
 | 
				
			||||||
 | 
					        secret_key: Flask secret key for sessions
 | 
				
			||||||
 | 
					        login_view: Route name for login page (default: 'login')
 | 
				
			||||||
 | 
					        use_bcrypt: If True, passwords are bcrypt hashes (default: True)
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, config):
 | 
				
			||||||
 | 
					        super().__init__(config)
 | 
				
			||||||
 | 
					        self.logger = logging.getLogger("tisbackup.auth")
 | 
				
			||||||
 | 
					        self.login_manager = None
 | 
				
			||||||
 | 
					        self.users = config.get("users", {})
 | 
				
			||||||
 | 
					        self.login_view = config.get("login_view", "login")
 | 
				
			||||||
 | 
					        self.use_bcrypt = config.get("use_bcrypt", True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.use_bcrypt:
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                import bcrypt
 | 
				
			||||||
 | 
					                self.bcrypt = bcrypt
 | 
				
			||||||
 | 
					            except ImportError:
 | 
				
			||||||
 | 
					                raise ImportError("bcrypt library required. Install with: pip install bcrypt")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Load users from file if path provided
 | 
				
			||||||
 | 
					        users_file = config.get("users_file")
 | 
				
			||||||
 | 
					        if users_file:
 | 
				
			||||||
 | 
					            self._load_users_from_file(users_file)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _load_users_from_file(self, filepath):
 | 
				
			||||||
 | 
					        """Load users from file (username:password_hash per line)."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            with open(filepath) as f:
 | 
				
			||||||
 | 
					                for line in f:
 | 
				
			||||||
 | 
					                    line = line.strip()
 | 
				
			||||||
 | 
					                    if line and not line.startswith('#'):
 | 
				
			||||||
 | 
					                        username, password_hash = line.split(':', 1)
 | 
				
			||||||
 | 
					                        self.users[username.strip()] = password_hash.strip()
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            self.logger.error(f"Failed to load users from {filepath}: {e}")
 | 
				
			||||||
 | 
					            raise
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def init_app(self, app):
 | 
				
			||||||
 | 
					        """Initialize Flask-Login with the app."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            from flask_login import LoginManager, UserMixin
 | 
				
			||||||
 | 
					        except ImportError:
 | 
				
			||||||
 | 
					            raise ImportError("flask-login library required. Install with: pip install flask-login")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.login_manager = LoginManager()
 | 
				
			||||||
 | 
					        self.login_manager.init_app(app)
 | 
				
			||||||
 | 
					        self.login_manager.login_view = self.login_view
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Simple User class
 | 
				
			||||||
 | 
					        class User(UserMixin):
 | 
				
			||||||
 | 
					            def __init__(self, username):
 | 
				
			||||||
 | 
					                self.id = username
 | 
				
			||||||
 | 
					                self.username = username
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.User = User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @self.login_manager.user_loader
 | 
				
			||||||
 | 
					        def load_user(user_id):
 | 
				
			||||||
 | 
					            if user_id in self.users:
 | 
				
			||||||
 | 
					                return User(user_id)
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def verify_password(self, username, password):
 | 
				
			||||||
 | 
					        """Verify username and password.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Args:
 | 
				
			||||||
 | 
					            username: Username to check
 | 
				
			||||||
 | 
					            password: Password to verify
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Returns:
 | 
				
			||||||
 | 
					            bool: True if valid, False otherwise
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if username not in self.users:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        stored_hash = self.users[username]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.use_bcrypt:
 | 
				
			||||||
 | 
					            password_bytes = password.encode('utf-8')
 | 
				
			||||||
 | 
					            hash_bytes = stored_hash.encode('utf-8') if isinstance(stored_hash, str) else stored_hash
 | 
				
			||||||
 | 
					            return self.bcrypt.checkpw(password_bytes, hash_bytes)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return password == stored_hash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def login_user(self, username):
 | 
				
			||||||
 | 
					        """Login a user by username.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Args:
 | 
				
			||||||
 | 
					            username: Username to login
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Returns:
 | 
				
			||||||
 | 
					            bool: True if successful
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            from flask_login import login_user
 | 
				
			||||||
 | 
					        except ImportError:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if username in self.users:
 | 
				
			||||||
 | 
					            user = self.User(username)
 | 
				
			||||||
 | 
					            login_user(user)
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_authenticated(self):
 | 
				
			||||||
 | 
					        """Check if current user is authenticated."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            from flask_login import current_user
 | 
				
			||||||
 | 
					            return current_user.is_authenticated
 | 
				
			||||||
 | 
					        except ImportError:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_unauthorized(self):
 | 
				
			||||||
 | 
					        """Redirect to login page."""
 | 
				
			||||||
 | 
					        return redirect(url_for(self.login_view))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_current_user(self):
 | 
				
			||||||
 | 
					        """Get current authenticated user."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            from flask_login import current_user
 | 
				
			||||||
 | 
					            if current_user.is_authenticated:
 | 
				
			||||||
 | 
					                return {
 | 
				
			||||||
 | 
					                    "username": current_user.username,
 | 
				
			||||||
 | 
					                    "auth_type": "flask-login"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					        except ImportError:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def logout(self):
 | 
				
			||||||
 | 
					        """Logout current user."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            from flask_login import logout_user
 | 
				
			||||||
 | 
					            logout_user()
 | 
				
			||||||
 | 
					        except ImportError:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
							
								
								
									
										164
									
								
								libtisbackup/auth/oauth_auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								libtisbackup/auth/oauth_auth.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,164 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/python3
 | 
				
			||||||
 | 
					# -*- coding: utf-8 -*-
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					OAuth2 authentication provider
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from functools import wraps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import redirect, request, session, url_for
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .base import AuthProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OAuthProvider(AuthProvider):
 | 
				
			||||||
 | 
					    """OAuth2 authentication provider.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Supports multiple OAuth providers (Google, GitHub, GitLab, etc.)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Configuration:
 | 
				
			||||||
 | 
					        provider: OAuth provider name ('google', 'github', 'gitlab', 'generic')
 | 
				
			||||||
 | 
					        client_id: OAuth client ID
 | 
				
			||||||
 | 
					        client_secret: OAuth client secret
 | 
				
			||||||
 | 
					        redirect_uri: OAuth redirect URI
 | 
				
			||||||
 | 
					        scopes: List of OAuth scopes (default: ['openid', 'email', 'profile'])
 | 
				
			||||||
 | 
					        authorized_domains: List of allowed email domains (optional)
 | 
				
			||||||
 | 
					        authorized_users: List of allowed email addresses (optional)
 | 
				
			||||||
 | 
					        login_view: Route name for login page (default: 'oauth_login')
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, config):
 | 
				
			||||||
 | 
					        super().__init__(config)
 | 
				
			||||||
 | 
					        self.logger = logging.getLogger("tisbackup.auth")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.provider_name = config.get("provider", "generic").lower()
 | 
				
			||||||
 | 
					        self.client_id = config.get("client_id")
 | 
				
			||||||
 | 
					        self.client_secret = config.get("client_secret")
 | 
				
			||||||
 | 
					        self.redirect_uri = config.get("redirect_uri")
 | 
				
			||||||
 | 
					        self.scopes = config.get("scopes", ["openid", "email", "profile"])
 | 
				
			||||||
 | 
					        self.authorized_domains = config.get("authorized_domains", [])
 | 
				
			||||||
 | 
					        self.authorized_users = config.get("authorized_users", [])
 | 
				
			||||||
 | 
					        self.login_view = config.get("login_view", "oauth_login")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not self.client_id or not self.client_secret:
 | 
				
			||||||
 | 
					            raise ValueError("OAuth requires 'client_id' and 'client_secret' in config")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Provider-specific configurations
 | 
				
			||||||
 | 
					        self.provider_configs = {
 | 
				
			||||||
 | 
					            "google": {
 | 
				
			||||||
 | 
					                "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
 | 
				
			||||||
 | 
					                "token_endpoint": "https://oauth2.googleapis.com/token",
 | 
				
			||||||
 | 
					                "userinfo_endpoint": "https://www.googleapis.com/oauth2/v2/userinfo",
 | 
				
			||||||
 | 
					                "default_scopes": ["openid", "email", "profile"],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            "github": {
 | 
				
			||||||
 | 
					                "authorization_endpoint": "https://github.com/login/oauth/authorize",
 | 
				
			||||||
 | 
					                "token_endpoint": "https://github.com/login/oauth/access_token",
 | 
				
			||||||
 | 
					                "userinfo_endpoint": "https://api.github.com/user",
 | 
				
			||||||
 | 
					                "default_scopes": ["read:user", "user:email"],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            "gitlab": {
 | 
				
			||||||
 | 
					                "authorization_endpoint": "https://gitlab.com/oauth/authorize",
 | 
				
			||||||
 | 
					                "token_endpoint": "https://gitlab.com/oauth/token",
 | 
				
			||||||
 | 
					                "userinfo_endpoint": "https://gitlab.com/api/v4/user",
 | 
				
			||||||
 | 
					                "default_scopes": ["read_user", "email"],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get provider config or use generic
 | 
				
			||||||
 | 
					        self.provider_config = self.provider_configs.get(self.provider_name, {})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Override with custom endpoints if provided
 | 
				
			||||||
 | 
					        self.authorization_endpoint = config.get(
 | 
				
			||||||
 | 
					            "authorization_endpoint",
 | 
				
			||||||
 | 
					            self.provider_config.get("authorization_endpoint")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.token_endpoint = config.get(
 | 
				
			||||||
 | 
					            "token_endpoint",
 | 
				
			||||||
 | 
					            self.provider_config.get("token_endpoint")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.userinfo_endpoint = config.get(
 | 
				
			||||||
 | 
					            "userinfo_endpoint",
 | 
				
			||||||
 | 
					            self.provider_config.get("userinfo_endpoint")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Use provider default scopes if not specified
 | 
				
			||||||
 | 
					        if not config.get("scopes"):
 | 
				
			||||||
 | 
					            self.scopes = self.provider_config.get("default_scopes", self.scopes)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def init_app(self, app):
 | 
				
			||||||
 | 
					        """Initialize OAuth with the app."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            from authlib.integrations.flask_client import OAuth
 | 
				
			||||||
 | 
					        except ImportError:
 | 
				
			||||||
 | 
					            raise ImportError(
 | 
				
			||||||
 | 
					                "authlib library required for OAuth. "
 | 
				
			||||||
 | 
					                "Install with: pip install authlib requests"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.oauth = OAuth(app)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Register OAuth client
 | 
				
			||||||
 | 
					        self.oauth_client = self.oauth.register(
 | 
				
			||||||
 | 
					            name=self.provider_name,
 | 
				
			||||||
 | 
					            client_id=self.client_id,
 | 
				
			||||||
 | 
					            client_secret=self.client_secret,
 | 
				
			||||||
 | 
					            server_metadata_url=None,
 | 
				
			||||||
 | 
					            authorize_url=self.authorization_endpoint,
 | 
				
			||||||
 | 
					            access_token_url=self.token_endpoint,
 | 
				
			||||||
 | 
					            userinfo_endpoint=self.userinfo_endpoint,
 | 
				
			||||||
 | 
					            client_kwargs={"scope": " ".join(self.scopes)},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_user_authorized(self, user_info):
 | 
				
			||||||
 | 
					        """Check if user is authorized based on email/domain.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Args:
 | 
				
			||||||
 | 
					            user_info: Dict with user information (must contain 'email')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Returns:
 | 
				
			||||||
 | 
					            bool: True if authorized
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        email = user_info.get("email", "").lower()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check specific users
 | 
				
			||||||
 | 
					        if self.authorized_users:
 | 
				
			||||||
 | 
					            if email in [u.lower() for u in self.authorized_users]:
 | 
				
			||||||
 | 
					                return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check domains
 | 
				
			||||||
 | 
					        if self.authorized_domains:
 | 
				
			||||||
 | 
					            domain = email.split("@")[-1] if "@" in email else ""
 | 
				
			||||||
 | 
					            if domain in [d.lower() for d in self.authorized_domains]:
 | 
				
			||||||
 | 
					                return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # If no restrictions configured, allow all
 | 
				
			||||||
 | 
					        if not self.authorized_users and not self.authorized_domains:
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_authenticated(self):
 | 
				
			||||||
 | 
					        """Check if current user is authenticated via OAuth."""
 | 
				
			||||||
 | 
					        return "oauth_user" in session
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_unauthorized(self):
 | 
				
			||||||
 | 
					        """Redirect to OAuth login."""
 | 
				
			||||||
 | 
					        return redirect(url_for(self.login_view))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_current_user(self):
 | 
				
			||||||
 | 
					        """Get current authenticated user."""
 | 
				
			||||||
 | 
					        user_info = session.get("oauth_user")
 | 
				
			||||||
 | 
					        if user_info:
 | 
				
			||||||
 | 
					            return {
 | 
				
			||||||
 | 
					                **user_info,
 | 
				
			||||||
 | 
					                "auth_type": "oauth",
 | 
				
			||||||
 | 
					                "provider": self.provider_name
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def logout(self):
 | 
				
			||||||
 | 
					        """Logout current user."""
 | 
				
			||||||
 | 
					        session.pop("oauth_user", None)
 | 
				
			||||||
 | 
					        session.pop("oauth_token", None)
 | 
				
			||||||
@ -58,11 +58,7 @@ class backup_mysql(backup_generic):
 | 
				
			|||||||
            raise Exception("backup destination directory already exists : %s" % self.dest_dir)
 | 
					            raise Exception("backup destination directory already exists : %s" % self.dest_dir)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
 | 
					        self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
 | 
				
			||||||
        try:
 | 
					        mykey = load_ssh_private_key(self.private_key)
 | 
				
			||||||
            mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
        except paramiko.SSHException:
 | 
					 | 
				
			||||||
            # mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
            mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.ssh = paramiko.SSHClient()
 | 
					        self.ssh = paramiko.SSHClient()
 | 
				
			||||||
        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
					        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
				
			||||||
 | 
				
			|||||||
@ -51,11 +51,7 @@ class backup_oracle(backup_generic):
 | 
				
			|||||||
        self.logger.debug(
 | 
					        self.logger.debug(
 | 
				
			||||||
            "[%s] Connecting to %s with user %s and key %s", self.backup_name, self.server_name, self.username, self.private_key
 | 
					            "[%s] Connecting to %s with user %s and key %s", self.backup_name, self.server_name, self.username, self.private_key
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        try:
 | 
					        mykey = load_ssh_private_key(self.private_key)
 | 
				
			||||||
            mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
        except paramiko.SSHException:
 | 
					 | 
				
			||||||
            # mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
            mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.ssh = paramiko.SSHClient()
 | 
					        self.ssh = paramiko.SSHClient()
 | 
				
			||||||
        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
					        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
				
			||||||
 | 
				
			|||||||
@ -53,11 +53,7 @@ class backup_pgsql(backup_generic):
 | 
				
			|||||||
        else:
 | 
					        else:
 | 
				
			||||||
            raise Exception("backup destination directory already exists : %s" % self.dest_dir)
 | 
					            raise Exception("backup destination directory already exists : %s" % self.dest_dir)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try:
 | 
					        mykey = load_ssh_private_key(self.private_key)
 | 
				
			||||||
            mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
        except paramiko.SSHException:
 | 
					 | 
				
			||||||
            # mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
            mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.logger.debug(
 | 
					        self.logger.debug(
 | 
				
			||||||
            '[%s] Trying to connect to "%s" with username root and key "%s"', self.backup_name, self.server_name, self.private_key
 | 
					            '[%s] Trying to connect to "%s" with username root and key "%s"', self.backup_name, self.server_name, self.private_key
 | 
				
			||||||
 | 
				
			|||||||
@ -54,11 +54,7 @@ class backup_samba4(backup_generic):
 | 
				
			|||||||
            raise Exception("backup destination directory already exists : %s" % self.dest_dir)
 | 
					            raise Exception("backup destination directory already exists : %s" % self.dest_dir)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
 | 
					        self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
 | 
				
			||||||
        try:
 | 
					        mykey = load_ssh_private_key(self.private_key)
 | 
				
			||||||
            mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
        except paramiko.SSHException:
 | 
					 | 
				
			||||||
            # mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
            mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.ssh = paramiko.SSHClient()
 | 
					        self.ssh = paramiko.SSHClient()
 | 
				
			||||||
        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
					        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
				
			||||||
 | 
				
			|||||||
@ -53,11 +53,7 @@ class backup_sqlserver(backup_generic):
 | 
				
			|||||||
    db_server_name = "localhost"
 | 
					    db_server_name = "localhost"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def do_backup(self, stats):
 | 
					    def do_backup(self, stats):
 | 
				
			||||||
        try:
 | 
					        mykey = load_ssh_private_key(self.private_key)
 | 
				
			||||||
            mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
        except paramiko.SSHException:
 | 
					 | 
				
			||||||
            # mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
            mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
 | 
					        self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
 | 
				
			||||||
        ssh = paramiko.SSHClient()
 | 
					        ssh = paramiko.SSHClient()
 | 
				
			||||||
 | 
				
			|||||||
@ -25,6 +25,7 @@ import os
 | 
				
			|||||||
import re
 | 
					import re
 | 
				
			||||||
import socket
 | 
					import socket
 | 
				
			||||||
import ssl
 | 
					import ssl
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
import tarfile
 | 
					import tarfile
 | 
				
			||||||
import urllib.error
 | 
					import urllib.error
 | 
				
			||||||
import urllib.parse
 | 
					import urllib.parse
 | 
				
			||||||
@ -196,10 +197,18 @@ class backup_xva(backup_generic):
 | 
				
			|||||||
            session.logout()
 | 
					            session.logout()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if os.path.exists(filename_temp):
 | 
					        if os.path.exists(filename_temp):
 | 
				
			||||||
            tar = os.system('tar tf "%s" > /dev/null' % filename_temp)
 | 
					            # Verify tar file integrity using subprocess instead of os.system
 | 
				
			||||||
            if not tar == 0:
 | 
					            try:
 | 
				
			||||||
 | 
					                subprocess.run(
 | 
				
			||||||
 | 
					                    ["tar", "tf", filename_temp],
 | 
				
			||||||
 | 
					                    capture_output=True,
 | 
				
			||||||
 | 
					                    check=True,
 | 
				
			||||||
 | 
					                    timeout=300
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
 | 
				
			||||||
                os.unlink(filename_temp)
 | 
					                os.unlink(filename_temp)
 | 
				
			||||||
                return "Tar error"
 | 
					                return "Tar error"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if str2bool(self.verify_export):
 | 
					            if str2bool(self.verify_export):
 | 
				
			||||||
                self.verify_export_xva(filename_temp)
 | 
					                self.verify_export_xva(filename_temp)
 | 
				
			||||||
            os.rename(filename_temp, filename)
 | 
					            os.rename(filename_temp, filename)
 | 
				
			||||||
 | 
				
			|||||||
@ -137,6 +137,48 @@ def check_string(test_string):
 | 
				
			|||||||
        print(("Invalid : %r" % (test_string,)))
 | 
					        print(("Invalid : %r" % (test_string,)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def load_ssh_private_key(private_key_path):
 | 
				
			||||||
 | 
					    """Load SSH private key with modern algorithm support.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Tries to load the key in order of preference:
 | 
				
			||||||
 | 
					    1. Ed25519 (most secure, modern)
 | 
				
			||||||
 | 
					    2. ECDSA (secure, widely supported)
 | 
				
			||||||
 | 
					    3. RSA (legacy, still secure with sufficient key size)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    DSA is not supported as it's deprecated and insecure.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Args:
 | 
				
			||||||
 | 
					        private_key_path: Path to the private key file
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Returns:
 | 
				
			||||||
 | 
					        paramiko key object
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Raises:
 | 
				
			||||||
 | 
					        paramiko.SSHException: If key cannot be loaded
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    key_types = [
 | 
				
			||||||
 | 
					        ("Ed25519", paramiko.Ed25519Key),
 | 
				
			||||||
 | 
					        ("ECDSA", paramiko.ECDSAKey),
 | 
				
			||||||
 | 
					        ("RSA", paramiko.RSAKey),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    last_exception = None
 | 
				
			||||||
 | 
					    for key_name, key_class in key_types:
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return key_class.from_private_key_file(private_key_path)
 | 
				
			||||||
 | 
					        except paramiko.SSHException as e:
 | 
				
			||||||
 | 
					            last_exception = e
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # If we get here, none of the key types worked
 | 
				
			||||||
 | 
					    raise paramiko.SSHException(
 | 
				
			||||||
 | 
					        f"Unable to load private key from {private_key_path}. "
 | 
				
			||||||
 | 
					        f"Supported formats: Ed25519 (recommended), ECDSA, RSA. "
 | 
				
			||||||
 | 
					        f"DSA keys are no longer supported. "
 | 
				
			||||||
 | 
					        f"Last error: {last_exception}"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def convert_bytes(bytes):
 | 
					def convert_bytes(bytes):
 | 
				
			||||||
    if bytes is None:
 | 
					    if bytes is None:
 | 
				
			||||||
        return None
 | 
					        return None
 | 
				
			||||||
@ -537,11 +579,7 @@ def ssh_exec(command, ssh=None, server_name="", remote_user="", private_key="",
 | 
				
			|||||||
    """
 | 
					    """
 | 
				
			||||||
    if not ssh:
 | 
					    if not ssh:
 | 
				
			||||||
        assert server_name and remote_user and private_key
 | 
					        assert server_name and remote_user and private_key
 | 
				
			||||||
        try:
 | 
					        mykey = load_ssh_private_key(private_key)
 | 
				
			||||||
            mykey = paramiko.RSAKey.from_private_key_file(private_key)
 | 
					 | 
				
			||||||
        except paramiko.SSHException:
 | 
					 | 
				
			||||||
            # mykey = paramiko.DSSKey.from_private_key_file(private_key)
 | 
					 | 
				
			||||||
            mykey = paramiko.Ed25519Key.from_private_key_file(private_key)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        ssh = paramiko.SSHClient()
 | 
					        ssh = paramiko.SSHClient()
 | 
				
			||||||
        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
					        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
				
			||||||
@ -640,14 +678,11 @@ class backup_generic(ABC):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def do_preexec(self, stats):
 | 
					    def do_preexec(self, stats):
 | 
				
			||||||
        self.logger.info("[%s] executing preexec %s ", self.backup_name, self.preexec)
 | 
					        self.logger.info("[%s] executing preexec %s ", self.backup_name, self.preexec)
 | 
				
			||||||
        try:
 | 
					        mykey = load_ssh_private_key(self.private_key)
 | 
				
			||||||
            mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
        except paramiko.SSHException:
 | 
					 | 
				
			||||||
            mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        ssh = paramiko.SSHClient()
 | 
					        ssh = paramiko.SSHClient()
 | 
				
			||||||
        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
					        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
				
			||||||
        ssh.connect(self.server_name, username=self.remote_user, pkey=mykey)
 | 
					        ssh.connect(self.server_name, username=self.remote_user, pkey=mykey, port=self.ssh_port)
 | 
				
			||||||
        tran = ssh.get_transport()
 | 
					        tran = ssh.get_transport()
 | 
				
			||||||
        chan = tran.open_session()
 | 
					        chan = tran.open_session()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -666,14 +701,11 @@ class backup_generic(ABC):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def do_postexec(self, stats):
 | 
					    def do_postexec(self, stats):
 | 
				
			||||||
        self.logger.info("[%s] executing postexec %s ", self.backup_name, self.postexec)
 | 
					        self.logger.info("[%s] executing postexec %s ", self.backup_name, self.postexec)
 | 
				
			||||||
        try:
 | 
					        mykey = load_ssh_private_key(self.private_key)
 | 
				
			||||||
            mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
        except paramiko.SSHException:
 | 
					 | 
				
			||||||
            mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        ssh = paramiko.SSHClient()
 | 
					        ssh = paramiko.SSHClient()
 | 
				
			||||||
        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
					        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 | 
				
			||||||
        ssh.connect(self.server_name, username=self.remote_user, pkey=mykey)
 | 
					        ssh.connect(self.server_name, username=self.remote_user, pkey=mykey, port=self.ssh_port)
 | 
				
			||||||
        tran = ssh.get_transport()
 | 
					        tran = ssh.get_transport()
 | 
				
			||||||
        chan = tran.open_session()
 | 
					        chan = tran.open_session()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -12,11 +12,29 @@ dependencies = [
 | 
				
			|||||||
    "pexpect==4.9.0",
 | 
					    "pexpect==4.9.0",
 | 
				
			||||||
    "redis==5.2.1",
 | 
					    "redis==5.2.1",
 | 
				
			||||||
    "requests==2.32.3",
 | 
					    "requests==2.32.3",
 | 
				
			||||||
 | 
					    "ruff>=0.13.3",
 | 
				
			||||||
    "simplejson==3.20.1",
 | 
					    "simplejson==3.20.1",
 | 
				
			||||||
    "six==1.17.0",
 | 
					    "six==1.17.0",
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
requires-python = ">=3.13"
 | 
					requires-python = ">=3.13"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[project.optional-dependencies]
 | 
				
			||||||
 | 
					# Authentication providers
 | 
				
			||||||
 | 
					auth-basic = ["bcrypt>=4.0.0"]
 | 
				
			||||||
 | 
					auth-login = ["flask-login>=0.6.0", "bcrypt>=4.0.0"]
 | 
				
			||||||
 | 
					auth-oauth = ["authlib>=1.3.0", "requests>=2.32.0"]
 | 
				
			||||||
 | 
					# Install all auth providers
 | 
				
			||||||
 | 
					auth-all = ["bcrypt>=4.0.0", "flask-login>=0.6.0", "authlib>=1.3.0", "requests>=2.32.0"]
 | 
				
			||||||
 | 
					# Documentation dependencies
 | 
				
			||||||
 | 
					docs = [
 | 
				
			||||||
 | 
					    "docutils",
 | 
				
			||||||
 | 
					    "sphinx>=7.0.0,<8.0.0",
 | 
				
			||||||
 | 
					    "sphinx_rtd_theme",
 | 
				
			||||||
 | 
					    "sphinxjp.themes.revealjs",
 | 
				
			||||||
 | 
					    "sphinx-intl",
 | 
				
			||||||
 | 
					    "sphinx-tabs",
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[tool.black]
 | 
					[tool.black]
 | 
				
			||||||
line-length = 140
 | 
					line-length = 140
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										130
									
								
								samples/auth-config-examples.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								samples/auth-config-examples.ini
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,130 @@
 | 
				
			|||||||
 | 
					# TISBackup Authentication Configuration Examples
 | 
				
			||||||
 | 
					# Add to tisbackup_gui.ini under [authentication] section
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					# Option 1: No Authentication (NOT RECOMMENDED)
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					# Option 2: HTTP Basic Authentication
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = basic
 | 
				
			||||||
 | 
					username = admin
 | 
				
			||||||
 | 
					# Plain text password (NOT RECOMMENDED for production)
 | 
				
			||||||
 | 
					password = changeme
 | 
				
			||||||
 | 
					use_bcrypt = False
 | 
				
			||||||
 | 
					realm = TISBackup Admin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# RECOMMENDED: Use bcrypt hash
 | 
				
			||||||
 | 
					# Generate hash with: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = basic
 | 
				
			||||||
 | 
					username = admin
 | 
				
			||||||
 | 
					password = $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
 | 
				
			||||||
 | 
					use_bcrypt = True
 | 
				
			||||||
 | 
					realm = TISBackup Admin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					# Option 3: Flask-Login (Username/Password with Sessions)
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = flask-login
 | 
				
			||||||
 | 
					# Users can be defined inline or in a file
 | 
				
			||||||
 | 
					users_file = /etc/tis/users.txt
 | 
				
			||||||
 | 
					use_bcrypt = True
 | 
				
			||||||
 | 
					login_view = login
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# User file format (users.txt):
 | 
				
			||||||
 | 
					# username:bcrypt_password_hash
 | 
				
			||||||
 | 
					# Example:
 | 
				
			||||||
 | 
					# admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
 | 
				
			||||||
 | 
					# operator:$2b$12$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNO
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					# Option 4: OAuth2 - Google
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = oauth
 | 
				
			||||||
 | 
					provider = google
 | 
				
			||||||
 | 
					client_id = your-client-id.apps.googleusercontent.com
 | 
				
			||||||
 | 
					client_secret = your-client-secret
 | 
				
			||||||
 | 
					redirect_uri = http://localhost:8080/oauth/callback
 | 
				
			||||||
 | 
					# Restrict to specific domains
 | 
				
			||||||
 | 
					authorized_domains = example.com,mycompany.com
 | 
				
			||||||
 | 
					# Or restrict to specific users
 | 
				
			||||||
 | 
					authorized_users = admin@example.com,backup-admin@example.com
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# To get Google OAuth credentials:
 | 
				
			||||||
 | 
					# 1. Go to https://console.cloud.google.com/apis/credentials
 | 
				
			||||||
 | 
					# 2. Create OAuth 2.0 Client ID
 | 
				
			||||||
 | 
					# 3. Add authorized redirect URI: http://your-server:8080/oauth/callback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					# Option 5: OAuth2 - GitHub
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = oauth
 | 
				
			||||||
 | 
					provider = github
 | 
				
			||||||
 | 
					client_id = your-github-client-id
 | 
				
			||||||
 | 
					client_secret = your-github-client-secret
 | 
				
			||||||
 | 
					redirect_uri = http://localhost:8080/oauth/callback
 | 
				
			||||||
 | 
					# Restrict to specific GitHub users (by email)
 | 
				
			||||||
 | 
					authorized_users = admin@example.com
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# To get GitHub OAuth credentials:
 | 
				
			||||||
 | 
					# 1. Go to Settings > Developer settings > OAuth Apps
 | 
				
			||||||
 | 
					# 2. Register a new application
 | 
				
			||||||
 | 
					# 3. Set Authorization callback URL: http://your-server:8080/oauth/callback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					# Option 6: OAuth2 - GitLab
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = oauth
 | 
				
			||||||
 | 
					provider = gitlab
 | 
				
			||||||
 | 
					client_id = your-gitlab-application-id
 | 
				
			||||||
 | 
					client_secret = your-gitlab-secret
 | 
				
			||||||
 | 
					redirect_uri = http://localhost:8080/oauth/callback
 | 
				
			||||||
 | 
					authorized_domains = example.com
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# To get GitLab OAuth credentials:
 | 
				
			||||||
 | 
					# 1. Go to User Settings > Applications
 | 
				
			||||||
 | 
					# 2. Create new application with scopes: read_user, email
 | 
				
			||||||
 | 
					# 3. Set Redirect URI: http://your-server:8080/oauth/callback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					# Option 7: OAuth2 - Generic Provider
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					[authentication]
 | 
				
			||||||
 | 
					type = oauth
 | 
				
			||||||
 | 
					provider = generic
 | 
				
			||||||
 | 
					client_id = your-client-id
 | 
				
			||||||
 | 
					client_secret = your-client-secret
 | 
				
			||||||
 | 
					redirect_uri = http://localhost:8080/oauth/callback
 | 
				
			||||||
 | 
					# Custom OAuth endpoints
 | 
				
			||||||
 | 
					authorization_endpoint = https://auth.example.com/oauth/authorize
 | 
				
			||||||
 | 
					token_endpoint = https://auth.example.com/oauth/token
 | 
				
			||||||
 | 
					userinfo_endpoint = https://auth.example.com/oauth/userinfo
 | 
				
			||||||
 | 
					scopes = openid,email,profile
 | 
				
			||||||
 | 
					authorized_domains = example.com
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					# Security Notes
 | 
				
			||||||
 | 
					# ============================================
 | 
				
			||||||
 | 
					# 1. Always use HTTPS in production (reverse proxy with TLS)
 | 
				
			||||||
 | 
					# 2. Set strong Flask secret_key via TISBACKUP_SECRET_KEY env var
 | 
				
			||||||
 | 
					# 3. For Basic Auth, always use bcrypt hashed passwords
 | 
				
			||||||
 | 
					# 4. For OAuth, restrict access via authorized_domains or authorized_users
 | 
				
			||||||
 | 
					# 5. Keep client secrets secure and never commit to version control
 | 
				
			||||||
 | 
					# 6. Regularly rotate OAuth client secrets
 | 
				
			||||||
 | 
					# 7. Use environment variables for sensitive data when possible
 | 
				
			||||||
							
								
								
									
										9
									
								
								tasks.py
									
									
									
									
									
								
							
							
						
						
									
										9
									
								
								tasks.py
									
									
									
									
									
								
							@ -1,5 +1,6 @@
 | 
				
			|||||||
import logging
 | 
					import logging
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from huey import RedisHuey
 | 
					from huey import RedisHuey
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -34,8 +35,12 @@ def run_export_backup(base, config_file, mount_point, backup_sections):
 | 
				
			|||||||
        return str(e)
 | 
					        return str(e)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    finally:
 | 
					    finally:
 | 
				
			||||||
        os.system("/bin/umount %s" % mount_point)
 | 
					        # Safely unmount using subprocess instead of os.system
 | 
				
			||||||
        os.rmdir(mount_point)
 | 
					        try:
 | 
				
			||||||
 | 
					            subprocess.run(["/bin/umount", mount_point], check=True, timeout=30)
 | 
				
			||||||
 | 
					            os.rmdir(mount_point)
 | 
				
			||||||
 | 
					        except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
 | 
				
			||||||
 | 
					            logger.error(f"Failed to unmount {mount_point}: {e}")
 | 
				
			||||||
    return "ok"
 | 
					    return "ok"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										227
									
								
								tisbackup_gui.py
									
									
									
									
									
								
							
							
						
						
									
										227
									
								
								tisbackup_gui.py
									
									
									
									
									
								
							@ -30,8 +30,9 @@ import glob
 | 
				
			|||||||
import json
 | 
					import json
 | 
				
			||||||
import logging
 | 
					import logging
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
 | 
					import shutil
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
from shutil import *
 | 
					 | 
				
			||||||
from urllib.parse import urlparse
 | 
					from urllib.parse import urlparse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from flask import Flask, Response, abort, appcontext_pushed, flash, g, jsonify, redirect, render_template, request, session, url_for
 | 
					from flask import Flask, Response, abort, appcontext_pushed, flash, g, jsonify, redirect, render_template, request, session, url_for
 | 
				
			||||||
@ -39,6 +40,7 @@ from iniparse import ConfigParser, RawConfigParser
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from config import huey
 | 
					from config import huey
 | 
				
			||||||
from libtisbackup.common import *
 | 
					from libtisbackup.common import *
 | 
				
			||||||
 | 
					from libtisbackup.auth import get_auth_provider
 | 
				
			||||||
from tasks import get_task, run_export_backup, set_task
 | 
					from tasks import get_task, run_export_backup, set_task
 | 
				
			||||||
from tisbackup import tis_backup
 | 
					from tisbackup import tis_backup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -61,9 +63,86 @@ mindate = None
 | 
				
			|||||||
error = None
 | 
					error = None
 | 
				
			||||||
info = None
 | 
					info = None
 | 
				
			||||||
app = Flask(__name__)
 | 
					app = Flask(__name__)
 | 
				
			||||||
app.secret_key = "fsiqefiuqsefARZ4Zfesfe34234dfzefzfe"
 | 
					
 | 
				
			||||||
 | 
					# Load secret key from environment variable or generate a secure random one
 | 
				
			||||||
 | 
					SECRET_KEY = os.environ.get("TISBACKUP_SECRET_KEY")
 | 
				
			||||||
 | 
					if not SECRET_KEY:
 | 
				
			||||||
 | 
					    # Generate a secure random secret key if not provided
 | 
				
			||||||
 | 
					    import secrets
 | 
				
			||||||
 | 
					    SECRET_KEY = secrets.token_hex(32)
 | 
				
			||||||
 | 
					    # Warn if using a random key (sessions won't persist across restarts)
 | 
				
			||||||
 | 
					    logging.warning(
 | 
				
			||||||
 | 
					        "TISBACKUP_SECRET_KEY environment variable not set. Using a randomly generated secret key. "
 | 
				
			||||||
 | 
					        "Sessions will not persist across application restarts. "
 | 
				
			||||||
 | 
					        "Set TISBACKUP_SECRET_KEY environment variable for production use."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.secret_key = SECRET_KEY
 | 
				
			||||||
app.config["PROPAGATE_EXCEPTIONS"] = True
 | 
					app.config["PROPAGATE_EXCEPTIONS"] = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Initialize authentication
 | 
				
			||||||
 | 
					auth_config = {}
 | 
				
			||||||
 | 
					try:
 | 
				
			||||||
 | 
					    # Read authentication config from tisbackup_gui.ini
 | 
				
			||||||
 | 
					    cp_gui = ConfigParser()
 | 
				
			||||||
 | 
					    cp_gui.read("/etc/tis/tisbackup_gui.ini")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if cp_gui.has_section("authentication"):
 | 
				
			||||||
 | 
					        auth_type = cp_gui.get("authentication", "type", fallback="basic")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Load auth provider config
 | 
				
			||||||
 | 
					        for key, value in cp_gui.items("authentication"):
 | 
				
			||||||
 | 
					            if key != "type":
 | 
				
			||||||
 | 
					                auth_config[key] = value
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        # Default to Basic Auth if no config section
 | 
				
			||||||
 | 
					        auth_type = "basic"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get credentials from environment or use defaults
 | 
				
			||||||
 | 
					        default_username = os.environ.get("TISBACKUP_AUTH_USERNAME", "admin")
 | 
				
			||||||
 | 
					        default_password = os.environ.get("TISBACKUP_AUTH_PASSWORD")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not default_password:
 | 
				
			||||||
 | 
					            # Generate random password if not set
 | 
				
			||||||
 | 
					            import secrets
 | 
				
			||||||
 | 
					            default_password = secrets.token_urlsafe(16)
 | 
				
			||||||
 | 
					            logging.warning(
 | 
				
			||||||
 | 
					                f"TISBACKUP_AUTH_PASSWORD not set. Generated temporary password for user '{default_username}': {default_password}"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            logging.warning(
 | 
				
			||||||
 | 
					                "Set TISBACKUP_AUTH_USERNAME and TISBACKUP_AUTH_PASSWORD environment variables, "
 | 
				
			||||||
 | 
					                "or add [authentication] section to tisbackup_gui.ini for production use."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        auth_config = {
 | 
				
			||||||
 | 
					            "username": default_username,
 | 
				
			||||||
 | 
					            "password": default_password,
 | 
				
			||||||
 | 
					            "use_bcrypt": False,  # Plain text for auto-generated password
 | 
				
			||||||
 | 
					            "realm": "TISBackup"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					except Exception as e:
 | 
				
			||||||
 | 
					    # Fallback to basic auth on error
 | 
				
			||||||
 | 
					    logging.error(f"Error loading authentication config: {e}. Using default Basic Auth.")
 | 
				
			||||||
 | 
					    auth_type = "basic"
 | 
				
			||||||
 | 
					    auth_config = {
 | 
				
			||||||
 | 
					        "username": os.environ.get("TISBACKUP_AUTH_USERNAME", "admin"),
 | 
				
			||||||
 | 
					        "password": os.environ.get("TISBACKUP_AUTH_PASSWORD", "changeme"),
 | 
				
			||||||
 | 
					        "use_bcrypt": False,
 | 
				
			||||||
 | 
					        "realm": "TISBackup"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Initialize auth provider
 | 
				
			||||||
 | 
					try:
 | 
				
			||||||
 | 
					    auth = get_auth_provider(auth_type, auth_config)
 | 
				
			||||||
 | 
					    auth.init_app(app)
 | 
				
			||||||
 | 
					    logging.info(f"Authentication initialized: {auth_type}")
 | 
				
			||||||
 | 
					except Exception as e:
 | 
				
			||||||
 | 
					    logging.error(f"Failed to initialize authentication: {e}")
 | 
				
			||||||
 | 
					    # Fallback to no auth
 | 
				
			||||||
 | 
					    auth = get_auth_provider("none", {})
 | 
				
			||||||
 | 
					    logging.warning("Authentication disabled due to initialization error")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
tasks_db = os.path.join(tisbackup_root_dir, "tasks.sqlite")
 | 
					tasks_db = os.path.join(tisbackup_root_dir, "tasks.sqlite")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -77,9 +156,10 @@ def read_all_configs(base_dir):
 | 
				
			|||||||
            raw_configs.append(join(base_dir, file))
 | 
					            raw_configs.append(join(base_dir, file))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for elem in raw_configs:
 | 
					    for elem in raw_configs:
 | 
				
			||||||
        line = open(elem).readline()
 | 
					        with open(elem) as f:
 | 
				
			||||||
        if "global" in line:
 | 
					            line = f.readline()
 | 
				
			||||||
            list_config.append(elem)
 | 
					            if "global" in line:
 | 
				
			||||||
 | 
					                list_config.append(elem)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    backup_dict = {}
 | 
					    backup_dict = {}
 | 
				
			||||||
    backup_dict["rsync_ssh_list"] = []
 | 
					    backup_dict["rsync_ssh_list"] = []
 | 
				
			||||||
@ -247,6 +327,7 @@ def read_config():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/")
 | 
					@app.route("/")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
def backup_all():
 | 
					def backup_all():
 | 
				
			||||||
    backup_dict = read_config()
 | 
					    backup_dict = read_config()
 | 
				
			||||||
    return render_template("backups.html", backup_list=backup_dict)
 | 
					    return render_template("backups.html", backup_list=backup_dict)
 | 
				
			||||||
@ -254,6 +335,7 @@ def backup_all():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@app.route("/config_number/")
 | 
					@app.route("/config_number/")
 | 
				
			||||||
@app.route("/config_number/<int:id>")
 | 
					@app.route("/config_number/<int:id>")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
def set_config_number(id=None):
 | 
					def set_config_number(id=None):
 | 
				
			||||||
    if id is not None and len(CONFIG) > id:
 | 
					    if id is not None and len(CONFIG) > id:
 | 
				
			||||||
        global config_number
 | 
					        global config_number
 | 
				
			||||||
@ -263,6 +345,7 @@ def set_config_number(id=None):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/all_json")
 | 
					@app.route("/all_json")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
def backup_all_json():
 | 
					def backup_all_json():
 | 
				
			||||||
    backup_dict = read_all_configs(BASE_DIR)
 | 
					    backup_dict = read_all_configs(BASE_DIR)
 | 
				
			||||||
    return json.dumps(
 | 
					    return json.dumps(
 | 
				
			||||||
@ -279,6 +362,7 @@ def backup_all_json():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/json")
 | 
					@app.route("/json")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
def backup_json():
 | 
					def backup_json():
 | 
				
			||||||
    backup_dict = read_config()
 | 
					    backup_dict = read_config()
 | 
				
			||||||
    return json.dumps(
 | 
					    return json.dumps(
 | 
				
			||||||
@ -296,12 +380,26 @@ def backup_json():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
def check_usb_disk():
 | 
					def check_usb_disk():
 | 
				
			||||||
    """This method returns the mounts point of FIRST external disk"""
 | 
					    """This method returns the mounts point of FIRST external disk"""
 | 
				
			||||||
    #    disk_name = []
 | 
					 | 
				
			||||||
    usb_disk_list = []
 | 
					    usb_disk_list = []
 | 
				
			||||||
    for name in glob.glob("/dev/sd[a-z]"):
 | 
					    for name in glob.glob("/dev/sd[a-z]"):
 | 
				
			||||||
        for line in os.popen("udevadm info -q env -n %s" % name):
 | 
					        # Validate device name to prevent command injection
 | 
				
			||||||
            if re.match("ID_PATH=.*usb.*", line):
 | 
					        if not re.match(r"^/dev/sd[a-z]$", name):
 | 
				
			||||||
                usb_disk_list += [name]
 | 
					            continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            result = subprocess.run(
 | 
				
			||||||
 | 
					                ["udevadm", "info", "-q", "env", "-n", name],
 | 
				
			||||||
 | 
					                capture_output=True,
 | 
				
			||||||
 | 
					                text=True,
 | 
				
			||||||
 | 
					                check=True,
 | 
				
			||||||
 | 
					                timeout=5
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            for line in result.stdout.splitlines():
 | 
				
			||||||
 | 
					                if re.match("ID_PATH=.*usb.*", line):
 | 
				
			||||||
 | 
					                    usb_disk_list.append(name)
 | 
				
			||||||
 | 
					                    break
 | 
				
			||||||
 | 
					        except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if len(usb_disk_list) == 0:
 | 
					    if len(usb_disk_list) == 0:
 | 
				
			||||||
        raise_error("Cannot find any external usb disk", "You should plug the usb hard drive into the server")
 | 
					        raise_error("Cannot find any external usb disk", "You should plug the usb hard drive into the server")
 | 
				
			||||||
@ -310,14 +408,27 @@ def check_usb_disk():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    usb_partition_list = []
 | 
					    usb_partition_list = []
 | 
				
			||||||
    for usb_disk in usb_disk_list:
 | 
					    for usb_disk in usb_disk_list:
 | 
				
			||||||
        cmd = "udevadm  info -q path -n %s" % usb_disk + "1"
 | 
					        partition = usb_disk + "1"
 | 
				
			||||||
        output = os.popen(cmd).read()
 | 
					        # Validate partition name
 | 
				
			||||||
        print("cmd : " + cmd)
 | 
					        if not re.match(r"^/dev/sd[a-z]1$", partition):
 | 
				
			||||||
        print("output : " + output)
 | 
					            continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if "/devices/pci" in output:
 | 
					        try:
 | 
				
			||||||
            # flash("partition found: %s1" % usb_disk)
 | 
					            result = subprocess.run(
 | 
				
			||||||
            usb_partition_list.append(usb_disk + "1")
 | 
					                ["udevadm", "info", "-q", "path", "-n", partition],
 | 
				
			||||||
 | 
					                capture_output=True,
 | 
				
			||||||
 | 
					                text=True,
 | 
				
			||||||
 | 
					                check=True,
 | 
				
			||||||
 | 
					                timeout=5
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            output = result.stdout
 | 
				
			||||||
 | 
					            print("partition check: " + partition)
 | 
				
			||||||
 | 
					            print("output : " + output)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if "/devices/pci" in output:
 | 
				
			||||||
 | 
					                usb_partition_list.append(partition)
 | 
				
			||||||
 | 
					        except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    print(usb_partition_list)
 | 
					    print(usb_partition_list)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -330,9 +441,22 @@ def check_usb_disk():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    tisbackup_partition_list = []
 | 
					    tisbackup_partition_list = []
 | 
				
			||||||
    for usb_partition in usb_partition_list:
 | 
					    for usb_partition in usb_partition_list:
 | 
				
			||||||
        if "tisbackup" in os.popen("/sbin/dumpe2fs -h %s 2>&1 |/bin/grep 'volume name'" % usb_partition).read().lower():
 | 
					        # Validate partition name to prevent command injection
 | 
				
			||||||
            flash("tisbackup backup partition found: %s" % usb_partition)
 | 
					        if not re.match(r"^/dev/sd[a-z]1$", usb_partition):
 | 
				
			||||||
            tisbackup_partition_list.append(usb_partition)
 | 
					            continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            result = subprocess.run(
 | 
				
			||||||
 | 
					                ["/sbin/dumpe2fs", "-h", usb_partition],
 | 
				
			||||||
 | 
					                capture_output=True,
 | 
				
			||||||
 | 
					                text=True,
 | 
				
			||||||
 | 
					                timeout=5
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            if "tisbackup" in result.stdout.lower():
 | 
				
			||||||
 | 
					                flash("tisbackup backup partition found: %s" % usb_partition)
 | 
				
			||||||
 | 
					                tisbackup_partition_list.append(usb_partition)
 | 
				
			||||||
 | 
					        except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    print(tisbackup_partition_list)
 | 
					    print(tisbackup_partition_list)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -351,27 +475,64 @@ def check_usb_disk():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def check_already_mount(partition_name, refresh):
 | 
					def check_already_mount(partition_name, refresh):
 | 
				
			||||||
 | 
					    # Validate partition name to prevent path traversal
 | 
				
			||||||
 | 
					    if not re.match(r"^/dev/[a-z0-9]+$", partition_name):
 | 
				
			||||||
 | 
					        raise_error("Invalid partition name", "Partition name contains invalid characters")
 | 
				
			||||||
 | 
					        return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    with open("/proc/mounts") as f:
 | 
					    with open("/proc/mounts") as f:
 | 
				
			||||||
        mount_point = ""
 | 
					        mount_point = ""
 | 
				
			||||||
        for line in f.readlines():
 | 
					        for line in f.readlines():
 | 
				
			||||||
            if line.startswith(partition_name):
 | 
					            if line.startswith(partition_name):
 | 
				
			||||||
                mount_point = line.split(" ")[1]
 | 
					                mount_point = line.split(" ")[1]
 | 
				
			||||||
            if not refresh:
 | 
					            if not refresh and mount_point:
 | 
				
			||||||
                run_command("/bin/umount %s" % mount_point)
 | 
					                try:
 | 
				
			||||||
                os.rmdir(mount_point)
 | 
					                    subprocess.run(["/bin/umount", mount_point], check=True, timeout=30)
 | 
				
			||||||
 | 
					                    os.rmdir(mount_point)
 | 
				
			||||||
 | 
					                except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
 | 
				
			||||||
 | 
					                    raise_error(f"Failed to unmount {mount_point}", str(e))
 | 
				
			||||||
    return mount_point
 | 
					    return mount_point
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def run_command(cmd, info=""):
 | 
					def run_command(cmd_list, info=""):
 | 
				
			||||||
    flash("Executing: %s" % cmd)
 | 
					    """Execute a command safely using subprocess.run with list arguments.
 | 
				
			||||||
    from subprocess import CalledProcessError, check_output
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    result = ""
 | 
					    Args:
 | 
				
			||||||
    try:
 | 
					        cmd_list: List of command arguments (or string for backward compatibility)
 | 
				
			||||||
        result = check_output(cmd, stderr=subprocess.STDOUT, shell=True)
 | 
					        info: Additional info message on error
 | 
				
			||||||
    except CalledProcessError:
 | 
					    """
 | 
				
			||||||
        raise_error(result, info)
 | 
					    # Handle legacy string commands by converting to list
 | 
				
			||||||
    return result
 | 
					    if isinstance(cmd_list, str):
 | 
				
			||||||
 | 
					        flash(f"Executing (legacy): {cmd_list}")
 | 
				
			||||||
 | 
					        # This should be refactored - shell=True is unsafe
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            result = subprocess.run(
 | 
				
			||||||
 | 
					                cmd_list,
 | 
				
			||||||
 | 
					                capture_output=True,
 | 
				
			||||||
 | 
					                text=True,
 | 
				
			||||||
 | 
					                shell=True,
 | 
				
			||||||
 | 
					                timeout=30
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            if result.returncode != 0:
 | 
				
			||||||
 | 
					                raise_error(result.stderr or result.stdout, info)
 | 
				
			||||||
 | 
					            return result.stdout
 | 
				
			||||||
 | 
					        except subprocess.TimeoutExpired:
 | 
				
			||||||
 | 
					            raise_error("Command timeout", info)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        flash(f"Executing: {' '.join(cmd_list)}")
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            result = subprocess.run(
 | 
				
			||||||
 | 
					                cmd_list,
 | 
				
			||||||
 | 
					                capture_output=True,
 | 
				
			||||||
 | 
					                text=True,
 | 
				
			||||||
 | 
					                check=True,
 | 
				
			||||||
 | 
					                timeout=30
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return result.stdout
 | 
				
			||||||
 | 
					        except subprocess.CalledProcessError as e:
 | 
				
			||||||
 | 
					            raise_error(e.stderr or e.stdout, info)
 | 
				
			||||||
 | 
					        except subprocess.TimeoutExpired:
 | 
				
			||||||
 | 
					            raise_error("Command timeout", info)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def check_mount_disk(partition_name, refresh):
 | 
					def check_mount_disk(partition_name, refresh):
 | 
				
			||||||
@ -390,6 +551,7 @@ def check_mount_disk(partition_name, refresh):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/status.json")
 | 
					@app.route("/status.json")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
def export_backup_status():
 | 
					def export_backup_status():
 | 
				
			||||||
    exports = dbstat.query('select * from stats where TYPE="EXPORT" and backup_start>="%s"' % mindate)
 | 
					    exports = dbstat.query('select * from stats where TYPE="EXPORT" and backup_start>="%s"' % mindate)
 | 
				
			||||||
    error = ""
 | 
					    error = ""
 | 
				
			||||||
@ -410,18 +572,21 @@ def runnings_backups():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/backups.json")
 | 
					@app.route("/backups.json")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
def last_backup_json():
 | 
					def last_backup_json():
 | 
				
			||||||
    exports = dbstat.query('select * from stats where TYPE="BACKUP" ORDER BY backup_start DESC  ')
 | 
					    exports = dbstat.query('select * from stats where TYPE="BACKUP" ORDER BY backup_start DESC  ')
 | 
				
			||||||
    return Response(response=json.dumps(exports), status=200, mimetype="application/json")
 | 
					    return Response(response=json.dumps(exports), status=200, mimetype="application/json")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/last_backups")
 | 
					@app.route("/last_backups")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
def last_backup():
 | 
					def last_backup():
 | 
				
			||||||
    exports = dbstat.query('select * from stats where TYPE="BACKUP" ORDER BY backup_start DESC LIMIT 20 ')
 | 
					    exports = dbstat.query('select * from stats where TYPE="BACKUP" ORDER BY backup_start DESC LIMIT 20 ')
 | 
				
			||||||
    return render_template("last_backups.html", backups=exports)
 | 
					    return render_template("last_backups.html", backups=exports)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/export_backup")
 | 
					@app.route("/export_backup")
 | 
				
			||||||
 | 
					@auth.require_auth
 | 
				
			||||||
def export_backup():
 | 
					def export_backup():
 | 
				
			||||||
    raise_error("", "")
 | 
					    raise_error("", "")
 | 
				
			||||||
    backup_dict = read_config()
 | 
					    backup_dict = read_config()
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										662
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										662
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							@ -1,73 +1,103 @@
 | 
				
			|||||||
version = 1
 | 
					version = 1
 | 
				
			||||||
revision = 1
 | 
					revision = 2
 | 
				
			||||||
requires-python = ">=3.13"
 | 
					requires-python = ">=3.13"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "alabaster"
 | 
				
			||||||
 | 
					version = "0.7.16"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "authlib"
 | 
				
			||||||
 | 
					version = "1.6.5"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					dependencies = [
 | 
				
			||||||
 | 
					    { name = "cryptography" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "babel"
 | 
				
			||||||
 | 
					version = "2.17.0"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "bcrypt"
 | 
					name = "bcrypt"
 | 
				
			||||||
version = "4.3.0"
 | 
					version = "4.3.0"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719 },
 | 
					    { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001 },
 | 
					    { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451 },
 | 
					    { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792 },
 | 
					    { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752 },
 | 
					    { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762 },
 | 
					    { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384 },
 | 
					    { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329 },
 | 
					    { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241 },
 | 
					    { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617 },
 | 
					    { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751 },
 | 
					    { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965 },
 | 
					    { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316 },
 | 
					    { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752 },
 | 
					    { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019 },
 | 
					    { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174 },
 | 
					    { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870 },
 | 
					    { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601 },
 | 
					    { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660 },
 | 
					    { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083 },
 | 
					    { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237 },
 | 
					    { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737 },
 | 
					    { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741 },
 | 
					    { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472 },
 | 
					    { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606 },
 | 
					    { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867 },
 | 
					    { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589 },
 | 
					    { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794 },
 | 
					    { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969 },
 | 
					    { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158 },
 | 
					    { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285 },
 | 
					    { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583 },
 | 
					    { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896 },
 | 
					    { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492 },
 | 
					    { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213 },
 | 
					    { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162 },
 | 
					    { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856 },
 | 
					    { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726 },
 | 
					    { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664 },
 | 
					    { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128 },
 | 
					    { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598 },
 | 
					    { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799 },
 | 
					    { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "blinker"
 | 
					name = "blinker"
 | 
				
			||||||
version = "1.9.0"
 | 
					version = "1.9.0"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
 | 
					    { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "certifi"
 | 
					name = "certifi"
 | 
				
			||||||
version = "2025.1.31"
 | 
					version = "2025.1.31"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
 | 
					    { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -77,41 +107,41 @@ source = { registry = "https://pypi.org/simple" }
 | 
				
			|||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
    { name = "pycparser" },
 | 
					    { name = "pycparser" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
 | 
					    { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
 | 
					    { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
 | 
					    { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
 | 
					    { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
 | 
					    { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
 | 
					    { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
 | 
					    { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
 | 
					    { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
 | 
					    { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
 | 
					    { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
 | 
					    { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "charset-normalizer"
 | 
					name = "charset-normalizer"
 | 
				
			||||||
version = "3.4.1"
 | 
					version = "3.4.1"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
 | 
					    { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
 | 
					    { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
 | 
					    { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
 | 
					    { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
 | 
					    { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
 | 
					    { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
 | 
					    { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
 | 
					    { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
 | 
					    { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
 | 
					    { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
 | 
					    { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
 | 
					    { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
 | 
					    { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
 | 
					    { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -121,18 +151,18 @@ source = { registry = "https://pypi.org/simple" }
 | 
				
			|||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
    { name = "colorama", marker = "sys_platform == 'win32'" },
 | 
					    { name = "colorama", marker = "sys_platform == 'win32'" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
 | 
					    { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "colorama"
 | 
					name = "colorama"
 | 
				
			||||||
version = "0.4.6"
 | 
					version = "0.4.6"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
 | 
					    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -142,32 +172,41 @@ source = { registry = "https://pypi.org/simple" }
 | 
				
			|||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
    { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
 | 
					    { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807, upload-time = "2025-03-02T00:01:37.692Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361 },
 | 
					    { url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361, upload-time = "2025-03-02T00:00:06.528Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 },
 | 
					    { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350, upload-time = "2025-03-02T00:00:09.537Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 },
 | 
					    { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572, upload-time = "2025-03-02T00:00:12.03Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 },
 | 
					    { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124, upload-time = "2025-03-02T00:00:14.518Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 },
 | 
					    { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122, upload-time = "2025-03-02T00:00:17.212Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 },
 | 
					    { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831, upload-time = "2025-03-02T00:00:19.696Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 },
 | 
					    { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583, upload-time = "2025-03-02T00:00:22.488Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 },
 | 
					    { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753, upload-time = "2025-03-02T00:00:25.038Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 },
 | 
					    { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550, upload-time = "2025-03-02T00:00:26.929Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 },
 | 
					    { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367, upload-time = "2025-03-02T00:00:28.735Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843 },
 | 
					    { url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843, upload-time = "2025-03-02T00:00:30.592Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057 },
 | 
					    { url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057, upload-time = "2025-03-02T00:00:33.393Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789 },
 | 
					    { url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789, upload-time = "2025-03-02T00:00:36.009Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 },
 | 
					    { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919, upload-time = "2025-03-02T00:00:38.581Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 },
 | 
					    { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812, upload-time = "2025-03-02T00:00:42.934Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 },
 | 
					    { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571, upload-time = "2025-03-02T00:00:46.026Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 },
 | 
					    { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832, upload-time = "2025-03-02T00:00:48.647Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 },
 | 
					    { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719, upload-time = "2025-03-02T00:00:51.397Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 },
 | 
					    { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852, upload-time = "2025-03-02T00:00:53.317Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 },
 | 
					    { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906, upload-time = "2025-03-02T00:00:56.49Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 },
 | 
					    { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572, upload-time = "2025-03-02T00:00:59.995Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 },
 | 
					    { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631, upload-time = "2025-03-02T00:01:01.623Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 },
 | 
					    { url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792, upload-time = "2025-03-02T00:01:04.133Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 },
 | 
					    { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957, upload-time = "2025-03-02T00:01:06.987Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "docutils"
 | 
				
			||||||
 | 
					version = "0.21.2"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -181,24 +220,46 @@ dependencies = [
 | 
				
			|||||||
    { name = "jinja2" },
 | 
					    { name = "jinja2" },
 | 
				
			||||||
    { name = "werkzeug" },
 | 
					    { name = "werkzeug" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824, upload-time = "2024-11-13T18:24:38.127Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 },
 | 
					    { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979, upload-time = "2024-11-13T18:24:36.135Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "flask-login"
 | 
				
			||||||
 | 
					version = "0.6.3"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					dependencies = [
 | 
				
			||||||
 | 
					    { name = "flask" },
 | 
				
			||||||
 | 
					    { name = "werkzeug" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "huey"
 | 
					name = "huey"
 | 
				
			||||||
version = "2.5.3"
 | 
					version = "2.5.3"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/86/8c/2dfecf3705f5e522097b4e9fb6fb38e627a0340f8362cc05bd7a845e4279/huey-2.5.3.tar.gz", hash = "sha256:089fc72b97fd26a513f15b09925c56fad6abe4a699a1f0e902170b37e85163c7", size = 836918 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/86/8c/2dfecf3705f5e522097b4e9fb6fb38e627a0340f8362cc05bd7a845e4279/huey-2.5.3.tar.gz", hash = "sha256:089fc72b97fd26a513f15b09925c56fad6abe4a699a1f0e902170b37e85163c7", size = 836918, upload-time = "2025-03-19T14:46:51.614Z" }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "idna"
 | 
					name = "idna"
 | 
				
			||||||
version = "3.10"
 | 
					version = "3.10"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
 | 
					    { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "imagesize"
 | 
				
			||||||
 | 
					version = "1.4.1"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -208,18 +269,18 @@ source = { registry = "https://pypi.org/simple" }
 | 
				
			|||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
    { name = "six" },
 | 
					    { name = "six" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/9a/02beaf11fc9ea7829d3a9041536934cd03990e09c359724f99ee6bd2b41b/iniparse-0.5.tar.gz", hash = "sha256:932e5239d526e7acb504017bb707be67019ac428a6932368e6851691093aa842", size = 32233 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/4c/9a/02beaf11fc9ea7829d3a9041536934cd03990e09c359724f99ee6bd2b41b/iniparse-0.5.tar.gz", hash = "sha256:932e5239d526e7acb504017bb707be67019ac428a6932368e6851691093aa842", size = 32233, upload-time = "2020-01-29T14:12:35.567Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/5f/b0/4d357324948188e76154b332e119fb28e374c1ebe4d4f6bca729aaa44309/iniparse-0.5-py3-none-any.whl", hash = "sha256:db6ef1d8a02395448e0e7b17ac0aa28b8d338b632bbd1ffca08c02ddae32cf97", size = 24445 },
 | 
					    { url = "https://files.pythonhosted.org/packages/5f/b0/4d357324948188e76154b332e119fb28e374c1ebe4d4f6bca729aaa44309/iniparse-0.5-py3-none-any.whl", hash = "sha256:db6ef1d8a02395448e0e7b17ac0aa28b8d338b632bbd1ffca08c02ddae32cf97", size = 24445, upload-time = "2020-01-29T14:12:34.068Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "itsdangerous"
 | 
					name = "itsdangerous"
 | 
				
			||||||
version = "2.2.0"
 | 
					version = "2.2.0"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
 | 
					    { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -229,37 +290,46 @@ source = { registry = "https://pypi.org/simple" }
 | 
				
			|||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
    { name = "markupsafe" },
 | 
					    { name = "markupsafe" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
 | 
					    { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "markupsafe"
 | 
					name = "markupsafe"
 | 
				
			||||||
version = "3.0.2"
 | 
					version = "3.0.2"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
 | 
					    { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
 | 
					    { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
 | 
					    { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
 | 
					    { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
 | 
					    { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
 | 
					    { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
 | 
					    { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
 | 
					    { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
 | 
					    { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
 | 
					    { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
 | 
					    { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
 | 
					    { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
 | 
					    { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
 | 
					    { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
 | 
					    { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
 | 
					    { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
 | 
					    { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
 | 
					    { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
 | 
					    { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
 | 
					    { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "packaging"
 | 
				
			||||||
 | 
					version = "25.0"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -271,16 +341,16 @@ dependencies = [
 | 
				
			|||||||
    { name = "cryptography" },
 | 
					    { name = "cryptography" },
 | 
				
			||||||
    { name = "pynacl" },
 | 
					    { name = "pynacl" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/15/ad6ce226e8138315f2451c2aeea985bf35ee910afb477bae7477dc3a8f3b/paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822", size = 1566110 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/7d/15/ad6ce226e8138315f2451c2aeea985bf35ee910afb477bae7477dc3a8f3b/paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822", size = 1566110, upload-time = "2025-02-04T02:37:59.783Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298 },
 | 
					    { url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298, upload-time = "2025-02-04T02:37:57.672Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "peewee"
 | 
					name = "peewee"
 | 
				
			||||||
version = "3.17.9"
 | 
					version = "3.17.9"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/57/09/4393bd378e70b7fc3163ee83353cc27bb520010a5c2b3c924121e7e7e068/peewee-3.17.9.tar.gz", hash = "sha256:fe15cd001758e324c8e3ca8c8ed900e7397c2907291789e1efc383e66b9bc7a8", size = 3026085 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/57/09/4393bd378e70b7fc3163ee83353cc27bb520010a5c2b3c924121e7e7e068/peewee-3.17.9.tar.gz", hash = "sha256:fe15cd001758e324c8e3ca8c8ed900e7397c2907291789e1efc383e66b9bc7a8", size = 3026085, upload-time = "2025-02-05T16:30:35.774Z" }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "pexpect"
 | 
					name = "pexpect"
 | 
				
			||||||
@ -289,27 +359,36 @@ source = { registry = "https://pypi.org/simple" }
 | 
				
			|||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
    { name = "ptyprocess" },
 | 
					    { name = "ptyprocess" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 },
 | 
					    { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "ptyprocess"
 | 
					name = "ptyprocess"
 | 
				
			||||||
version = "0.7.0"
 | 
					version = "0.7.0"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 },
 | 
					    { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "pycparser"
 | 
					name = "pycparser"
 | 
				
			||||||
version = "2.22"
 | 
					version = "2.22"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
 | 
					    { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "pygments"
 | 
				
			||||||
 | 
					version = "2.19.2"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -319,26 +398,26 @@ source = { registry = "https://pypi.org/simple" }
 | 
				
			|||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
    { name = "cffi" },
 | 
					    { name = "cffi" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920 },
 | 
					    { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722 },
 | 
					    { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087 },
 | 
					    { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678 },
 | 
					    { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660 },
 | 
					    { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824 },
 | 
					    { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912 },
 | 
					    { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624 },
 | 
					    { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141 },
 | 
					    { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "redis"
 | 
					name = "redis"
 | 
				
			||||||
version = "5.2.1"
 | 
					version = "5.2.1"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355, upload-time = "2024-12-06T09:50:41.956Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502 },
 | 
					    { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502, upload-time = "2024-12-06T09:50:39.656Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -351,40 +430,218 @@ dependencies = [
 | 
				
			|||||||
    { name = "idna" },
 | 
					    { name = "idna" },
 | 
				
			||||||
    { name = "urllib3" },
 | 
					    { name = "urllib3" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
 | 
					    { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "ruff"
 | 
				
			||||||
 | 
					version = "0.13.3"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" },
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "setuptools"
 | 
				
			||||||
 | 
					version = "80.9.0"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "simplejson"
 | 
					name = "simplejson"
 | 
				
			||||||
version = "3.20.1"
 | 
					version = "3.20.1"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/af/92/51b417685abd96b31308b61b9acce7ec50d8e1de8fbc39a7fd4962c60689/simplejson-3.20.1.tar.gz", hash = "sha256:e64139b4ec4f1f24c142ff7dcafe55a22b811a74d86d66560c8815687143037d", size = 85591 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/af/92/51b417685abd96b31308b61b9acce7ec50d8e1de8fbc39a7fd4962c60689/simplejson-3.20.1.tar.gz", hash = "sha256:e64139b4ec4f1f24c142ff7dcafe55a22b811a74d86d66560c8815687143037d", size = 85591, upload-time = "2025-02-15T05:18:53.15Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/c4/03/0f453a27877cb5a5fff16a975925f4119102cc8552f52536b9a98ef0431e/simplejson-3.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:71e849e7ceb2178344998cbe5ade101f1b329460243c79c27fbfc51c0447a7c3", size = 93109 },
 | 
					    { url = "https://files.pythonhosted.org/packages/c4/03/0f453a27877cb5a5fff16a975925f4119102cc8552f52536b9a98ef0431e/simplejson-3.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:71e849e7ceb2178344998cbe5ade101f1b329460243c79c27fbfc51c0447a7c3", size = 93109, upload-time = "2025-02-15T05:17:00.377Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/74/1f/a729f4026850cabeaff23e134646c3f455e86925d2533463420635ae54de/simplejson-3.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b63fdbab29dc3868d6f009a59797cefaba315fd43cd32ddd998ee1da28e50e29", size = 75475 },
 | 
					    { url = "https://files.pythonhosted.org/packages/74/1f/a729f4026850cabeaff23e134646c3f455e86925d2533463420635ae54de/simplejson-3.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b63fdbab29dc3868d6f009a59797cefaba315fd43cd32ddd998ee1da28e50e29", size = 75475, upload-time = "2025-02-15T05:17:02.544Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/e2/14/50a2713fee8ff1f8d655b1a14f4a0f1c0c7246768a1b3b3d12964a4ed5aa/simplejson-3.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1190f9a3ce644fd50ec277ac4a98c0517f532cfebdcc4bd975c0979a9f05e1fb", size = 75112 },
 | 
					    { url = "https://files.pythonhosted.org/packages/e2/14/50a2713fee8ff1f8d655b1a14f4a0f1c0c7246768a1b3b3d12964a4ed5aa/simplejson-3.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1190f9a3ce644fd50ec277ac4a98c0517f532cfebdcc4bd975c0979a9f05e1fb", size = 75112, upload-time = "2025-02-15T05:17:03.875Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/45/86/ea9835abb646755140e2d482edc9bc1e91997ed19a59fd77ae4c6a0facea/simplejson-3.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1336ba7bcb722ad487cd265701ff0583c0bb6de638364ca947bb84ecc0015d1", size = 150245 },
 | 
					    { url = "https://files.pythonhosted.org/packages/45/86/ea9835abb646755140e2d482edc9bc1e91997ed19a59fd77ae4c6a0facea/simplejson-3.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1336ba7bcb722ad487cd265701ff0583c0bb6de638364ca947bb84ecc0015d1", size = 150245, upload-time = "2025-02-15T05:17:06.899Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/12/b4/53084809faede45da829fe571c65fbda8479d2a5b9c633f46b74124d56f5/simplejson-3.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e975aac6a5acd8b510eba58d5591e10a03e3d16c1cf8a8624ca177491f7230f0", size = 158465 },
 | 
					    { url = "https://files.pythonhosted.org/packages/12/b4/53084809faede45da829fe571c65fbda8479d2a5b9c633f46b74124d56f5/simplejson-3.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e975aac6a5acd8b510eba58d5591e10a03e3d16c1cf8a8624ca177491f7230f0", size = 158465, upload-time = "2025-02-15T05:17:08.707Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/a9/7d/d56579468d1660b3841e1f21c14490d103e33cf911886b22652d6e9683ec/simplejson-3.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a6dd11ee282937ad749da6f3b8d87952ad585b26e5edfa10da3ae2536c73078", size = 148514 },
 | 
					    { url = "https://files.pythonhosted.org/packages/a9/7d/d56579468d1660b3841e1f21c14490d103e33cf911886b22652d6e9683ec/simplejson-3.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a6dd11ee282937ad749da6f3b8d87952ad585b26e5edfa10da3ae2536c73078", size = 148514, upload-time = "2025-02-15T05:17:11.323Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/19/e3/874b1cca3d3897b486d3afdccc475eb3a09815bf1015b01cf7fcb52a55f0/simplejson-3.20.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab980fcc446ab87ea0879edad41a5c28f2d86020014eb035cf5161e8de4474c6", size = 152262 },
 | 
					    { url = "https://files.pythonhosted.org/packages/19/e3/874b1cca3d3897b486d3afdccc475eb3a09815bf1015b01cf7fcb52a55f0/simplejson-3.20.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab980fcc446ab87ea0879edad41a5c28f2d86020014eb035cf5161e8de4474c6", size = 152262, upload-time = "2025-02-15T05:17:13.543Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/32/84/f0fdb3625292d945c2bd13a814584603aebdb38cfbe5fe9be6b46fe598c4/simplejson-3.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f5aee2a4cb6b146bd17333ac623610f069f34e8f31d2f4f0c1a2186e50c594f0", size = 150164 },
 | 
					    { url = "https://files.pythonhosted.org/packages/32/84/f0fdb3625292d945c2bd13a814584603aebdb38cfbe5fe9be6b46fe598c4/simplejson-3.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f5aee2a4cb6b146bd17333ac623610f069f34e8f31d2f4f0c1a2186e50c594f0", size = 150164, upload-time = "2025-02-15T05:17:15.021Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/95/51/6d625247224f01eaaeabace9aec75ac5603a42f8ebcce02c486fbda8b428/simplejson-3.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:652d8eecbb9a3b6461b21ec7cf11fd0acbab144e45e600c817ecf18e4580b99e", size = 151795 },
 | 
					    { url = "https://files.pythonhosted.org/packages/95/51/6d625247224f01eaaeabace9aec75ac5603a42f8ebcce02c486fbda8b428/simplejson-3.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:652d8eecbb9a3b6461b21ec7cf11fd0acbab144e45e600c817ecf18e4580b99e", size = 151795, upload-time = "2025-02-15T05:17:16.542Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/7f/d9/bb921df6b35be8412f519e58e86d1060fddf3ad401b783e4862e0a74c4c1/simplejson-3.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8c09948f1a486a89251ee3a67c9f8c969b379f6ffff1a6064b41fea3bce0a112", size = 159027 },
 | 
					    { url = "https://files.pythonhosted.org/packages/7f/d9/bb921df6b35be8412f519e58e86d1060fddf3ad401b783e4862e0a74c4c1/simplejson-3.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8c09948f1a486a89251ee3a67c9f8c969b379f6ffff1a6064b41fea3bce0a112", size = 159027, upload-time = "2025-02-15T05:17:18.083Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/03/c5/5950605e4ad023a6621cf4c931b29fd3d2a9c1f36be937230bfc83d7271d/simplejson-3.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cbbd7b215ad4fc6f058b5dd4c26ee5c59f72e031dfda3ac183d7968a99e4ca3a", size = 154380 },
 | 
					    { url = "https://files.pythonhosted.org/packages/03/c5/5950605e4ad023a6621cf4c931b29fd3d2a9c1f36be937230bfc83d7271d/simplejson-3.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cbbd7b215ad4fc6f058b5dd4c26ee5c59f72e031dfda3ac183d7968a99e4ca3a", size = 154380, upload-time = "2025-02-15T05:17:20.334Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/66/ad/b74149557c5ec1e4e4d55758bda426f5d2ec0123cd01a53ae63b8de51fa3/simplejson-3.20.1-cp313-cp313-win32.whl", hash = "sha256:ae81e482476eaa088ef9d0120ae5345de924f23962c0c1e20abbdff597631f87", size = 74102 },
 | 
					    { url = "https://files.pythonhosted.org/packages/66/ad/b74149557c5ec1e4e4d55758bda426f5d2ec0123cd01a53ae63b8de51fa3/simplejson-3.20.1-cp313-cp313-win32.whl", hash = "sha256:ae81e482476eaa088ef9d0120ae5345de924f23962c0c1e20abbdff597631f87", size = 74102, upload-time = "2025-02-15T05:17:22.475Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/db/a9/25282fdd24493e1022f30b7f5cdf804255c007218b2bfaa655bd7ad34b2d/simplejson-3.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:1b9fd15853b90aec3b1739f4471efbf1ac05066a2c7041bf8db821bb73cd2ddc", size = 75736 },
 | 
					    { url = "https://files.pythonhosted.org/packages/db/a9/25282fdd24493e1022f30b7f5cdf804255c007218b2bfaa655bd7ad34b2d/simplejson-3.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:1b9fd15853b90aec3b1739f4471efbf1ac05066a2c7041bf8db821bb73cd2ddc", size = 75736, upload-time = "2025-02-15T05:17:24.122Z" },
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/4b/30/00f02a0a921556dd5a6db1ef2926a1bc7a8bbbfb1c49cfed68a275b8ab2b/simplejson-3.20.1-py3-none-any.whl", hash = "sha256:8a6c1bbac39fa4a79f83cbf1df6ccd8ff7069582a9fd8db1e52cea073bc2c697", size = 57121 },
 | 
					    { url = "https://files.pythonhosted.org/packages/4b/30/00f02a0a921556dd5a6db1ef2926a1bc7a8bbbfb1c49cfed68a275b8ab2b/simplejson-3.20.1-py3-none-any.whl", hash = "sha256:8a6c1bbac39fa4a79f83cbf1df6ccd8ff7069582a9fd8db1e52cea073bc2c697", size = 57121, upload-time = "2025-02-15T05:18:51.243Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "six"
 | 
					name = "six"
 | 
				
			||||||
version = "1.17.0"
 | 
					version = "1.17.0"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
 | 
					    { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "snowballstemmer"
 | 
				
			||||||
 | 
					version = "3.0.1"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinx"
 | 
				
			||||||
 | 
					version = "7.4.7"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					dependencies = [
 | 
				
			||||||
 | 
					    { name = "alabaster" },
 | 
				
			||||||
 | 
					    { name = "babel" },
 | 
				
			||||||
 | 
					    { name = "colorama", marker = "sys_platform == 'win32'" },
 | 
				
			||||||
 | 
					    { name = "docutils" },
 | 
				
			||||||
 | 
					    { name = "imagesize" },
 | 
				
			||||||
 | 
					    { name = "jinja2" },
 | 
				
			||||||
 | 
					    { name = "packaging" },
 | 
				
			||||||
 | 
					    { name = "pygments" },
 | 
				
			||||||
 | 
					    { name = "requests" },
 | 
				
			||||||
 | 
					    { name = "snowballstemmer" },
 | 
				
			||||||
 | 
					    { name = "sphinxcontrib-applehelp" },
 | 
				
			||||||
 | 
					    { name = "sphinxcontrib-devhelp" },
 | 
				
			||||||
 | 
					    { name = "sphinxcontrib-htmlhelp" },
 | 
				
			||||||
 | 
					    { name = "sphinxcontrib-jsmath" },
 | 
				
			||||||
 | 
					    { name = "sphinxcontrib-qthelp" },
 | 
				
			||||||
 | 
					    { name = "sphinxcontrib-serializinghtml" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinx-intl"
 | 
				
			||||||
 | 
					version = "2.3.2"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					dependencies = [
 | 
				
			||||||
 | 
					    { name = "babel" },
 | 
				
			||||||
 | 
					    { name = "click" },
 | 
				
			||||||
 | 
					    { name = "sphinx" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/7a/21/eb12016ecb0b52861762b0d227dff75622988f238776a5ee4c75bade507e/sphinx_intl-2.3.2.tar.gz", hash = "sha256:04b0d8ea04d111a7ba278b17b7b3fe9625c58b6f8ffb78bb8a1dd1288d88c1c7", size = 27921, upload-time = "2025-08-02T04:53:01.891Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/f7/3b/156032fa29a87e9eba9182b8e893a7e88c1d98907a078a371d69be432e52/sphinx_intl-2.3.2-py3-none-any.whl", hash = "sha256:f0082f9383066bab8406129a2ed531d21c38706d08467bf5ca3714e8914bb2be", size = 12899, upload-time = "2025-08-02T04:53:00.353Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinx-rtd-theme"
 | 
				
			||||||
 | 
					version = "0.5.1"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					dependencies = [
 | 
				
			||||||
 | 
					    { name = "sphinx" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/4e/e5/0d55470572e0a0934c600c4cda0c98209883aaeb45ff6bfbadcda7006255/sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5", size = 2774928, upload-time = "2021-01-04T22:57:24.103Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/76/81/d5af3a50a45ee4311ac2dac5b599d69f68388401c7a4ca902e0e450a9f94/sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113", size = 2793140, upload-time = "2021-01-04T22:57:15.177Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinx-tabs"
 | 
				
			||||||
 | 
					version = "3.4.7"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					dependencies = [
 | 
				
			||||||
 | 
					    { name = "docutils" },
 | 
				
			||||||
 | 
					    { name = "pygments" },
 | 
				
			||||||
 | 
					    { name = "sphinx" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/6a/53/a9a91995cb365e589f413b77fc75f1c0e9b4ac61bfa8da52a779ad855cc0/sphinx-tabs-3.4.7.tar.gz", hash = "sha256:991ad4a424ff54119799ba1491701aa8130dd43509474aef45a81c42d889784d", size = 15891, upload-time = "2024-10-08T13:37:27.887Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/6b/c6/f47505b564b918a3ba60c1e99232d4942c4a7e44ecaae603e829e3d05dae/sphinx_tabs-3.4.7-py3-none-any.whl", hash = "sha256:c12d7a36fd413b369e9e9967a0a4015781b71a9c393575419834f19204bd1915", size = 9727, upload-time = "2024-10-08T13:37:26.192Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-applehelp"
 | 
				
			||||||
 | 
					version = "2.0.0"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-devhelp"
 | 
				
			||||||
 | 
					version = "2.0.0"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-htmlhelp"
 | 
				
			||||||
 | 
					version = "2.1.0"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-jsmath"
 | 
				
			||||||
 | 
					version = "1.0.1"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-qthelp"
 | 
				
			||||||
 | 
					version = "2.0.0"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-serializinghtml"
 | 
				
			||||||
 | 
					version = "2.0.0"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxjp-themes-revealjs"
 | 
				
			||||||
 | 
					version = "0.3.0"
 | 
				
			||||||
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
 | 
					dependencies = [
 | 
				
			||||||
 | 
					    { name = "setuptools" },
 | 
				
			||||||
 | 
					    { name = "sphinx" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/69/ef/b386f35b6caf57f158da97d594df78f07eaf42fd0044b361fe5fd143238b/sphinxjp.themes.revealjs-0.3.0.tar.gz", hash = "sha256:293ea6a91bcb74a19648373f0194854ddd7757bb578407627c8ef73caaa9ed3c", size = 5462188, upload-time = "2015-05-06T13:35:39.5Z" }
 | 
				
			||||||
 | 
					wheels = [
 | 
				
			||||||
 | 
					    { url = "https://files.pythonhosted.org/packages/b4/8f/538bbbd5e1c63330eb8793d0e9c2faa466a7b82e2f8007a5c2e196e2c0d1/sphinxjp.themes.revealjs-0.3.0-py2.py3-none-any.whl", hash = "sha256:631914655ed37241a808452c1d95a0dfcef92c1c42cdcfb2f031a11cb0366689", size = 1637836, upload-time = "2015-05-06T13:35:47.844Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -400,13 +657,49 @@ dependencies = [
 | 
				
			|||||||
    { name = "pexpect" },
 | 
					    { name = "pexpect" },
 | 
				
			||||||
    { name = "redis" },
 | 
					    { name = "redis" },
 | 
				
			||||||
    { name = "requests" },
 | 
					    { name = "requests" },
 | 
				
			||||||
 | 
					    { name = "ruff" },
 | 
				
			||||||
    { name = "simplejson" },
 | 
					    { name = "simplejson" },
 | 
				
			||||||
    { name = "six" },
 | 
					    { name = "six" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.optional-dependencies]
 | 
				
			||||||
 | 
					auth-all = [
 | 
				
			||||||
 | 
					    { name = "authlib" },
 | 
				
			||||||
 | 
					    { name = "bcrypt" },
 | 
				
			||||||
 | 
					    { name = "flask-login" },
 | 
				
			||||||
 | 
					    { name = "requests" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					auth-basic = [
 | 
				
			||||||
 | 
					    { name = "bcrypt" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					auth-login = [
 | 
				
			||||||
 | 
					    { name = "bcrypt" },
 | 
				
			||||||
 | 
					    { name = "flask-login" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					auth-oauth = [
 | 
				
			||||||
 | 
					    { name = "authlib" },
 | 
				
			||||||
 | 
					    { name = "requests" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					docs = [
 | 
				
			||||||
 | 
					    { name = "docutils" },
 | 
				
			||||||
 | 
					    { name = "sphinx" },
 | 
				
			||||||
 | 
					    { name = "sphinx-intl" },
 | 
				
			||||||
 | 
					    { name = "sphinx-rtd-theme" },
 | 
				
			||||||
 | 
					    { name = "sphinx-tabs" },
 | 
				
			||||||
 | 
					    { name = "sphinxjp-themes-revealjs" },
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[package.metadata]
 | 
					[package.metadata]
 | 
				
			||||||
requires-dist = [
 | 
					requires-dist = [
 | 
				
			||||||
 | 
					    { name = "authlib", marker = "extra == 'auth-all'", specifier = ">=1.3.0" },
 | 
				
			||||||
 | 
					    { name = "authlib", marker = "extra == 'auth-oauth'", specifier = ">=1.3.0" },
 | 
				
			||||||
 | 
					    { name = "bcrypt", marker = "extra == 'auth-all'", specifier = ">=4.0.0" },
 | 
				
			||||||
 | 
					    { name = "bcrypt", marker = "extra == 'auth-basic'", specifier = ">=4.0.0" },
 | 
				
			||||||
 | 
					    { name = "bcrypt", marker = "extra == 'auth-login'", specifier = ">=4.0.0" },
 | 
				
			||||||
 | 
					    { name = "docutils", marker = "extra == 'docs'" },
 | 
				
			||||||
    { name = "flask", specifier = "==3.1.0" },
 | 
					    { name = "flask", specifier = "==3.1.0" },
 | 
				
			||||||
 | 
					    { name = "flask-login", marker = "extra == 'auth-all'", specifier = ">=0.6.0" },
 | 
				
			||||||
 | 
					    { name = "flask-login", marker = "extra == 'auth-login'", specifier = ">=0.6.0" },
 | 
				
			||||||
    { name = "huey", specifier = "==2.5.3" },
 | 
					    { name = "huey", specifier = "==2.5.3" },
 | 
				
			||||||
    { name = "iniparse", specifier = "==0.5" },
 | 
					    { name = "iniparse", specifier = "==0.5" },
 | 
				
			||||||
    { name = "paramiko", specifier = "==3.5.1" },
 | 
					    { name = "paramiko", specifier = "==3.5.1" },
 | 
				
			||||||
@ -414,17 +707,26 @@ requires-dist = [
 | 
				
			|||||||
    { name = "pexpect", specifier = "==4.9.0" },
 | 
					    { name = "pexpect", specifier = "==4.9.0" },
 | 
				
			||||||
    { name = "redis", specifier = "==5.2.1" },
 | 
					    { name = "redis", specifier = "==5.2.1" },
 | 
				
			||||||
    { name = "requests", specifier = "==2.32.3" },
 | 
					    { name = "requests", specifier = "==2.32.3" },
 | 
				
			||||||
 | 
					    { name = "requests", marker = "extra == 'auth-all'", specifier = ">=2.32.0" },
 | 
				
			||||||
 | 
					    { name = "requests", marker = "extra == 'auth-oauth'", specifier = ">=2.32.0" },
 | 
				
			||||||
 | 
					    { name = "ruff", specifier = ">=0.13.3" },
 | 
				
			||||||
    { name = "simplejson", specifier = "==3.20.1" },
 | 
					    { name = "simplejson", specifier = "==3.20.1" },
 | 
				
			||||||
    { name = "six", specifier = "==1.17.0" },
 | 
					    { name = "six", specifier = "==1.17.0" },
 | 
				
			||||||
 | 
					    { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0.0,<8.0.0" },
 | 
				
			||||||
 | 
					    { name = "sphinx-intl", marker = "extra == 'docs'" },
 | 
				
			||||||
 | 
					    { name = "sphinx-rtd-theme", marker = "extra == 'docs'" },
 | 
				
			||||||
 | 
					    { name = "sphinx-tabs", marker = "extra == 'docs'" },
 | 
				
			||||||
 | 
					    { name = "sphinxjp-themes-revealjs", marker = "extra == 'docs'" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					provides-extras = ["auth-basic", "auth-login", "auth-oauth", "auth-all", "docs"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "urllib3"
 | 
					name = "urllib3"
 | 
				
			||||||
version = "2.4.0"
 | 
					version = "2.4.0"
 | 
				
			||||||
source = { registry = "https://pypi.org/simple" }
 | 
					source = { registry = "https://pypi.org/simple" }
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 },
 | 
					    { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
@ -434,7 +736,7 @@ source = { registry = "https://pypi.org/simple" }
 | 
				
			|||||||
dependencies = [
 | 
					dependencies = [
 | 
				
			||||||
    { name = "markupsafe" },
 | 
					    { name = "markupsafe" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 }
 | 
					sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" }
 | 
				
			||||||
wheels = [
 | 
					wheels = [
 | 
				
			||||||
    { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 },
 | 
					    { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" },
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user