05/22 Implement license version

This commit is contained in:
2026-05-22 16:31:19 -04:00
parent e6657e60cf
commit 687216e332
16 changed files with 1373 additions and 37 deletions
+84
View File
@@ -645,6 +645,90 @@ proxies; static files are served directly).
---
## 22. License Key System (added 2026-05-22)
### Overview
TechDesk ships with an offline RSA-signed license key system for commercial
distribution as a self-hosted SaaS product. No phone-home is required — the
key itself encodes tier and expiry, verified by a baked-in public key.
### Tiers
| Tier | Features |
|---|---|
| Community (no key) | Core ticketing, KB, user management, bulk actions, CSV export |
| Business | Everything above + AI Chatbot, SLA tracking, weekly digest, ticket watchers, time tracking, satisfaction surveys |
| Enterprise | Everything above + email ingestion (IMAP) |
### Key Format
```
TDESK-<payload_b64>.<signature_b64>
```
- `payload_b64` = `base64url(UTF-8 JSON bytes)`
- `signature_b64` = `base64url(RSA-SHA256 signature of payload bytes)`
- JSON payload: `{ customer, email, tier, issued_at, expires_at }`
- RSA-2048 with PKCS1v15 padding and SHA-256
### License Server (`license_server/`)
Vendor-hosted internal Flask app. **Never expose to the public internet.**
- `generate_keys.py` — run once to produce `private_key.pem` and `public_key.pem`
- `app.py` — password-protected UI; `GET /` lists issued keys, `POST /generate` creates a new key
- `licenses.db` — SQLite log of all issued keys
- Runs on `127.0.0.1:5001`; set `LICENSE_SERVER_PASSWORD` env var
**Rule:** `private_key.pem` must NEVER be committed to git or included in any customer build.
After generating, copy `public_key.pem` contents into `PUBLIC_KEY_PEM` in `license_service.py`.
### TechDesk License Client (`app/services/license_service.py`)
| Function | Description |
|---|---|
| `validate_license(key_string)` | Verify RSA signature + expiry. Returns status dict. |
| `get_status()` | Read cached `app.config['LICENSE_STATUS']`. Requires app context. |
| `feature_enabled(feature_name)` | True if current tier grants access. Requires app context. |
| `days_until_expiry()` | Days remaining, or None if invalid. |
**Status dict keys:** `valid`, `tier`, `customer`, `email`, `issued_at`, `expires_at`, `days_left`, `reason`
**Reason codes:** `no_key`, `no_public_key`, `expired`, `invalid_signature`, `invalid_format`
**Feature name constants:** `chatbot`, `sla`, `digest`, `watchers`, `time_tracking`, `surveys`, `email_ingestion`
### Integration Points
- **`config/config.py`** — `LICENSE_KEY = os.environ.get('LICENSE_KEY', '')`
- **`app/__init__.py`** — validates on startup, stores in `app.config['LICENSE_STATUS']`; gates SLA/digest/email-ingestion APScheduler jobs by tier
- **`inject_globals` context processor** — injects `license` dict into all templates
- **`app/routes/admin.py`** — `GET /admin/license` shows status + feature checklist
- **`app/routes/chatbot.py`** — `feature_enabled('chatbot')` gate; returns 403 JSON if unlicensed
- **`app/routes/tickets.py`** — `feature_enabled('watchers')` on watch/unwatch; `feature_enabled('time_tracking')` on log-time
- **`app/services/sla_service.py`** — `check_sla_breaches` returns early if `feature_enabled('sla')` is False
- **`app/services/notification_service.py`** — `send_weekly_digest` returns early if `feature_enabled('digest')` is False
- **`app/templates/base.html`** — admin-only warning banner when `license.valid` is False or expiry < 30 days; chatbot FAB hidden for Community tier
- **`app/templates/tickets/detail.html`** — watcher card + time tracking card hidden for Community tier
### Behavior Rules
- **Soft failure always** — unlicensed app never crashes or hard-blocks; features degrade gracefully
- **Warning banner is admin-only** — employees see no license-related UI
- **Public key is safe to ship** — it can only verify, not forge signatures
- **Startup log** — `[LICENSE]` prefix in app log shows tier, customer, expiry on every start
### Setup Steps for a New Customer Key
1. On your license server: run `python app.py`, log in, fill in customer details, click Generate
2. Copy the full `TDESK-...` key from the UI
3. Send the key to the customer
4. Customer adds `LICENSE_KEY=TDESK-...` to their `.env` and restarts: `sudo systemctl restart gunicorn`
5. Customer visits `Admin → License` to confirm activation
---
*Last updated: 2026-05-22*
## 18. Notification Architecture
+386
View File
@@ -0,0 +1,386 @@
# TechDesk Licensing — Vendor Operations Guide
This document covers everything you need to do as the vendor:
setting up the license server, generating keys, publishing the app to customers,
and issuing keys. Read it top to bottom the first time, then jump to the relevant
section for ongoing operations.
---
## Table of Contents
1. [One-Time Setup (Do This First)](#1-one-time-setup-do-this-first)
2. [Deploy the License Server](#2-deploy-the-license-server)
3. [Publish TechDesk to a Customer](#3-publish-techdesk-to-a-customer)
4. [Issue a Key — Current Customer (First Time)](#4-issue-a-key--current-customer-first-time)
5. [Customer Applies the Key](#5-customer-applies-the-key)
6. [Renew an Expiring Key](#6-renew-an-expiring-key)
7. [Ongoing Operations Checklist](#7-ongoing-operations-checklist)
---
## 1. One-Time Setup (Do This First)
This section is done **once, ever**. It generates the RSA key pair that secures
all license keys. Do it on a machine you control — your development machine or
the server where the license server will run.
### Step 1.1 — Install dependencies
```bash
cd license_server
pip install -r requirements.txt
```
### Step 1.2 — Generate the RSA key pair
```bash
python generate_keys.py
```
Output:
```
Key pair generated successfully.
Private key : private_key.pem (KEEP SECRET)
Public key : public_key.pem (safe to embed in TechDesk)
```
Two files are created:
| File | What it is | Who sees it |
|---|---|---|
| `private_key.pem` | Signs all license keys | You only. Never leave this machine. |
| `public_key.pem` | Verifies license keys | Embedded in TechDesk. Customers have it. |
> **Critical:** `private_key.pem` must NEVER be committed to git, sent to
> customers, or included in any build. If it leaks, anyone can forge unlimited
> valid license keys. Back it up securely (encrypted drive, password manager).
### Step 1.3 — Embed the public key in TechDesk
Open `app/services/license_service.py` and find the `PUBLIC_KEY_PEM` constant
near the top. Replace the placeholder with the full contents of `public_key.pem`:
```python
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
(your actual key here)
...
-----END PUBLIC KEY-----"""
```
Include the `-----BEGIN PUBLIC KEY-----` and `-----END PUBLIC KEY-----` lines.
> After this change, TechDesk will validate license keys against your real
> public key. Before this step, all keys fail and the app runs in Community mode.
### Step 1.4 — Commit the public key change
```bash
git add app/services/license_service.py
git commit -m "Embed RSA public key for license validation"
```
This is the only license-related change that goes into the customer-facing
codebase. `private_key.pem` stays off git permanently — add it to `.gitignore`:
```bash
echo "license_server/private_key.pem" >> .gitignore
echo "license_server/licenses.db" >> .gitignore
git add .gitignore
git commit -m "Ignore license server secrets from git"
```
---
## 2. Deploy the License Server
The license server is a small Flask app that runs **on your machine or a private
server**. It does not need to be reachable from the internet — you only access
it when generating new keys.
### Option A — Run locally on your development machine (simplest)
Good for low volume (fewer than 50 customers). Run it only when you need to
generate a key, not 24/7.
```bash
cd license_server
# Set a strong admin password (or set the env var in your shell profile)
export LICENSE_SERVER_PASSWORD="your-strong-password-here"
python app.py
```
Open `http://127.0.0.1:5001` in your browser. Log in with the password you set.
### Option B — Run on a private VPS (recommended for production)
If you prefer a persistent server so you can generate keys from anywhere:
1. Copy the `license_server/` directory to your VPS (via `scp` or git)
2. Copy `private_key.pem` to the VPS **via a secure channel** (not email)
3. Set environment variables:
```bash
export LICENSE_SERVER_PASSWORD="your-strong-password-here"
export SECRET_KEY="random-64-char-string-here"
```
4. Run with gunicorn behind nginx (or just run directly if access is restricted
to your IP via firewall):
```bash
pip install -r requirements.txt
python app.py
```
5. Restrict access to your IP in the VPS firewall — port 5001 should never be
publicly reachable.
> **Important:** The license server has no multi-user auth, no HTTPS, and no
> rate limiting. It is designed for internal use only. Never expose port 5001
> to the internet.
---
## 3. Publish TechDesk to a Customer
When you deliver TechDesk to a new customer, you give them the codebase.
Here is exactly what to include and exclude.
### What to include
Everything in the project **except** the items listed below.
### What to EXCLUDE
| Path | Why |
|---|---|
| `license_server/private_key.pem` | Signing key — leaking it lets anyone forge keys |
| `license_server/licenses.db` | Your customer database — not their business |
| `license_server/` (entire folder) | Customers don't need the server |
| `.env` | Contains your secrets, not theirs |
| `logs/` | Your logs |
| `app/static/uploads/` | Your data |
| `migrations/` (optional) | Only needed if they'll run migrations manually |
### Recommended delivery method
**Option A: Git archive (cleanest)**
```bash
# From your project root — produces a zip with no git history and no excluded files
git archive --format=zip --output=techdesk-v1.0.zip HEAD
```
Then manually remove `license_server/` from the zip if it's tracked in git:
```bash
zip -d techdesk-v1.0.zip "license_server/*"
```
**Option B: Manual zip**
```bash
# Exclude everything sensitive
zip -r techdesk-v1.0.zip . \
--exclude "*.git*" \
--exclude "*.env*" \
--exclude "license_server/*" \
--exclude "logs/*" \
--exclude "app/static/uploads/*"
```
**Checklist before sending:**
- [ ] `app/services/license_service.py` contains the real public key (not the placeholder)
- [ ] `license_server/` is not in the archive
- [ ] No `.env` file in the archive
- [ ] No `private_key.pem` anywhere in the archive
---
## 4. Issue a Key — Current Customer (First Time)
Your existing customer is running TechDesk without a license key right now
(Community mode). Here is how to issue them a key.
### Step 4.1 — Start the license server
```bash
cd license_server
export LICENSE_SERVER_PASSWORD="your-password"
python app.py
```
Open `http://127.0.0.1:5001` and log in.
### Step 4.2 — Fill in the Generate form
| Field | What to enter |
|---|---|
| Customer / Company Name | Their company name (e.g. `LT Services Inc`) |
| Contact Email | Their admin's email address |
| Tier | `Business` (recommended starting tier) |
| Duration | `1 year` (365 days) — gives you an annual renewal cycle |
Click **Generate Key**.
### Step 4.3 — Copy the key
The full key appears on screen immediately after generation, like:
```
TDESK-eyJjdXN0b21lciI6Ikx....(long string)....
```
**Copy the entire key** — it is shown in full only once on screen. The database
stores only a short preview for your records. If you lose it, generate a new one.
### Step 4.4 — Send the key to the customer
Send the key via email, Slack, or your preferred channel. Include:
- The full `TDESK-...` key string
- The expiry date (1 year from today)
- A link to the setup instructions below (or paste them directly)
**Example email:**
> Subject: Your TechDesk License Key
>
> Hi [Name],
>
> Your TechDesk Business license key is below. It is valid until [expiry date].
>
> ```
> TDESK-eyJjdXN0b21lciI6...
> ```
>
> To activate:
> 1. Open your `.env` file on the TechDesk server
> 2. Add this line: `LICENSE_KEY=TDESK-eyJ...` (the full key)
> 3. Restart TechDesk: `sudo systemctl restart gunicorn`
> 4. Visit Admin → License to confirm activation
>
> Let me know if you have any questions.
### Step 4.5 — Verify the key was recorded
Back in the license server UI, confirm the new row appears in the Issued Licenses
table with the correct customer name, tier, and expiry date. This is your audit
trail.
---
## 5. Customer Applies the Key
Share these instructions with the customer. They only need to do this once (and
again on renewal).
### Customer instructions
**On the TechDesk server:**
1. Open the `.env` file in the TechDesk installation directory:
```bash
nano /home/it-ticket/myapp/.env
```
2. Add or update this line (paste the full key you received):
```
LICENSE_KEY=TDESK-eyJjdXN0b21lciI6...
```
3. Save the file and restart TechDesk:
```bash
sudo systemctl restart gunicorn
```
4. Verify activation — log in to TechDesk as admin and go to
**Admin → License**. The page should show:
- A **Business** (or Enterprise) tier badge
- Your company name and email
- The expiry date
- Green checkmarks on all licensed features
5. The warning banner at the top of the page (if it was showing) will disappear.
**If something goes wrong:**
| Symptom | Likely cause | Fix |
|---|---|---|
| Banner still shows "No license key" | `.env` not saved or service not restarted | Re-check `.env`, restart gunicorn |
| Banner shows "License key invalid" | Key was truncated or modified during copy | Re-copy the full key, ensure no line breaks |
| License page shows "Public key not configured" | The public key placeholder was not replaced | Contact vendor — the app build is missing the public key |
---
## 6. Renew an Expiring Key
30 days before expiry the customer will see a blue banner: _"Your license expires
in X days."_ When expiry passes, it turns to a yellow warning and features lock
down to Community tier.
### Renewal process
1. Start the license server (same as Step 4.1)
2. Generate a new key for the customer (same as Step 4.24.3)
- Use the same customer name, email, and tier
- Choose the same duration (1 year)
3. Send the new key to the customer (same as Step 4.4)
4. Customer replaces the old `LICENSE_KEY=` line in `.env` with the new key and
restarts — same as Step 5
> The old key and new key are independent — the customer simply overwrites the
> `.env` value. There is no overlap or grace period to manage on your end.
---
## 7. Ongoing Operations Checklist
### When you generate a new version of TechDesk
- [ ] Ensure `app/services/license_service.py` still contains the real `PUBLIC_KEY_PEM`
(it should, unless you accidentally reverted it)
- [ ] Exclude `license_server/` and `.env` from the customer build
- [ ] Test: start the app with no `LICENSE_KEY` → confirm Community mode banner appears
- [ ] Test: start the app with a valid key → confirm correct tier and features
### When a customer reports a licensing issue
1. Ask them to go to **Admin → License** and send you a screenshot
2. Check the reason shown:
- `no_key` → they haven't added the `.env` line or haven't restarted
- `expired` → they need a renewal key
- `invalid_signature` → the key was corrupted in transit; re-send it
- `no_public_key` → their build is missing the public key; send a fixed build
### If `private_key.pem` is ever compromised
All previously issued keys remain valid (you cannot invalidate them — offline
model has no revocation). Your only recourse:
1. Generate a new RSA key pair (`python generate_keys.py` after deleting the old files)
2. Embed the new `public_key.pem` in TechDesk
3. Re-issue keys to all existing customers
4. Send them the updated build with the new public key
This is the main limitation of offline keys — treat `private_key.pem` with the
same care as a master password.
### Backing up `private_key.pem`
Store at least two encrypted copies:
- A password manager (Bitwarden, 1Password, etc.) as a secure note
- An encrypted USB drive stored offline
If you lose `private_key.pem` you cannot issue new keys and cannot recover
existing ones — you would need to do a full key rotation (same as the compromise
scenario above).
---
*Last updated: 2026-05-22*
+67 -31
View File
@@ -179,8 +179,12 @@ def create_app(config_name=None):
except Exception:
_zone = _ZI('UTC')
now_local = _dt.now(_tz.utc).astimezone(_zone)
license_status = current_app.config.get(
'LICENSE_STATUS', {'valid': False, 'tier': 'community', 'reason': 'no_key'}
)
return dict(unread_notifications=unread, branding=branding,
now_local=now_local, app_tz_name=_tz_name)
now_local=now_local, app_tz_name=_tz_name,
license=license_status)
# ── Security headers ──────────────────────────────────────────────────────
@app.after_request
@@ -199,6 +203,27 @@ def create_app(config_name=None):
_seed_settings()
_seed_ticket_templates()
# ── License validation ────────────────────────────────────────────────────
# Validate on startup and cache the result in app.config so every request
# can read it instantly without hitting disk or doing crypto again.
# The result is also injected into Jinja2 templates via inject_globals.
from app.services.license_service import validate_license
_license_status = validate_license(app.config.get('LICENSE_KEY', ''))
app.config['LICENSE_STATUS'] = _license_status
_lic_tier = _license_status.get('tier', 'community')
if _license_status.get('valid'):
app.logger.info(
f'[LICENSE] {_lic_tier.upper()} license valid for '
f'{_license_status.get("customer")}'
f'expires {_license_status.get("expires_at")} '
f'({_license_status.get("days_left")} days left)'
)
else:
app.logger.warning(
f'[LICENSE] No valid license ({_license_status.get("reason", "unknown")}) — '
f'running in Community mode'
)
# ── Background schedulers (SLA + Email Ingestion) ───────────────────────────
# Both jobs share a single BackgroundScheduler instance to avoid duplicate
# thread pools and simplify startup/shutdown. The scheduler is only started
@@ -241,40 +266,51 @@ def _start_background_schedulers(app):
scheduler = BackgroundScheduler(daemon=True)
# SLA breach check — every 30 minutes
scheduler.add_job(
func = check_sla_breaches,
trigger = IntervalTrigger(minutes=30),
id = 'sla_check',
name = 'SLA Breach Check',
replace_existing = True,
args = [app],
)
_lic = app.config.get('LICENSE_STATUS', {})
_lic_tier = _lic.get('tier', 'community')
_is_business = _lic_tier in ('business', 'enterprise')
_is_enterprise = _lic_tier == 'enterprise'
# Inbound email ingestion — interval from SystemSetting
scheduler.add_job(
func = check_inbound_email,
trigger = IntervalTrigger(minutes=email_interval),
id = 'email_ingest',
name = 'Inbound Email Ingestion',
replace_existing = True,
args = [app],
)
# SLA breach check — Business+ only, every 30 minutes
if _is_business:
scheduler.add_job(
func = check_sla_breaches,
trigger = IntervalTrigger(minutes=30),
id = 'sla_check',
name = 'SLA Breach Check',
replace_existing = True,
args = [app],
)
# Weekly digest — every Monday at 08:00 UTC
scheduler.add_job(
func = send_weekly_digest,
trigger = CronTrigger(day_of_week='mon', hour=8, minute=0),
id = 'weekly_digest',
name = 'Weekly IT Digest Email',
replace_existing = True,
args = [app],
)
# Inbound email ingestion — Enterprise only
if _is_enterprise:
scheduler.add_job(
func = check_inbound_email,
trigger = IntervalTrigger(minutes=email_interval),
id = 'email_ingest',
name = 'Inbound Email Ingestion',
replace_existing = True,
args = [app],
)
# Weekly digest — Business+ only, every Monday at 08:00 UTC
if _is_business:
scheduler.add_job(
func = send_weekly_digest,
trigger = CronTrigger(day_of_week='mon', hour=8, minute=0),
id = 'weekly_digest',
name = 'Weekly IT Digest Email',
replace_existing = True,
args = [app],
)
scheduler.start()
app.logger.info('[SCHEDULER] APScheduler started — SLA check every 30 min, '
f'email ingestion every {email_interval} min, '
'weekly digest every Monday 08:00 UTC')
app.logger.info(
f'[SCHEDULER] APScheduler started — tier={_lic_tier} '
+ ('SLA check every 30 min, ' if _is_business else '')
+ (f'email ingestion every {email_interval} min, ' if _is_enterprise else '')
+ ('weekly digest every Monday 08:00 UTC' if _is_business else '')
)
except Exception as exc:
app.logger.error(f'[SCHEDULER] Failed to start APScheduler: {exc}')
+17
View File
@@ -1572,3 +1572,20 @@ def email_ingestion_run_now():
except Exception as exc:
logger.error(f'[ADMIN EMAIL INGEST] Manual run error: {exc}', exc_info=True)
return jsonify(ok=False, message=f'Run failed: {exc}')
@admin_bp.route('/license')
@login_required
@admin_required
def license_page():
from app.services.license_service import get_status, days_until_expiry, TIER_FEATURES
status = get_status()
days_left = days_until_expiry()
tier = status.get('tier', 'community')
features = TIER_FEATURES.get(tier, set())
return render_template(
'admin/license.html',
status = status,
days_left = days_left,
features = features,
)
+8
View File
@@ -130,6 +130,14 @@ def _call_groq(api_key, history, user_msg, kb_context=''):
@login_required
@limiter.limit('20 per minute; 100 per hour')
def chat():
from app.services.license_service import feature_enabled
if not feature_enabled('chatbot'):
return jsonify({
'reply' : 'The AI chatbot is not available on the Community plan. '
'Upgrade to a Business or Enterprise license to enable it.',
'ticket': None,
}), 403
data = request.get_json(force=True)
history = data.get('history', []) # [{role, content}, ...]
user_msg = data.get('message', '').strip()
+15 -2
View File
@@ -999,6 +999,10 @@ def ticket_survey(token):
@tickets_bp.route('/tickets/<int:ticket_id>/watch', methods=['POST'])
@login_required
def watch_ticket(ticket_id):
from app.services.license_service import feature_enabled
from flask import jsonify
if not feature_enabled('watchers'):
return jsonify({'error': 'Ticket watchers require a Business or Enterprise license.'}), 403
ticket = db.session.get(Ticket, ticket_id) or abort(404)
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
abort(403)
@@ -1006,19 +1010,21 @@ def watch_ticket(ticket_id):
if not existing:
db.session.add(TicketWatcher(ticket_id=ticket_id, user_id=current_user.id))
db.session.commit()
from flask import jsonify
return jsonify({'watching': True})
@tickets_bp.route('/tickets/<int:ticket_id>/unwatch', methods=['POST'])
@login_required
def unwatch_ticket(ticket_id):
from app.services.license_service import feature_enabled
from flask import jsonify
if not feature_enabled('watchers'):
return jsonify({'error': 'Ticket watchers require a Business or Enterprise license.'}), 403
ticket = db.session.get(Ticket, ticket_id) or abort(404)
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
abort(403)
TicketWatcher.query.filter_by(ticket_id=ticket_id, user_id=current_user.id).delete()
db.session.commit()
from flask import jsonify
return jsonify({'watching': False})
@@ -1027,6 +1033,13 @@ def unwatch_ticket(ticket_id):
@tickets_bp.route('/tickets/<int:ticket_id>/log-time', methods=['POST'])
@login_required
def log_time(ticket_id):
from app.services.license_service import feature_enabled
if not feature_enabled('time_tracking'):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
from flask import jsonify
return jsonify({'error': 'Time tracking requires a Business or Enterprise license.'}), 403
flash('Time tracking requires a Business or Enterprise license.', 'warning')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
if not current_user.is_it_staff:
abort(403)
ticket = db.session.get(Ticket, ticket_id) or abort(404)
+177
View File
@@ -0,0 +1,177 @@
"""
TechDesk License Service offline RSA-signed key validation.
Key format: TDESK-<payload_b64>.<signature_b64>
payload_b64 = base64url( UTF-8 JSON bytes )
signature_b64 = base64url( RSA-SHA256 signature of those bytes )
JSON payload fields:
customer company name
email contact email
tier "community" | "business" | "enterprise"
issued_at ISO date (YYYY-MM-DD)
expires_at ISO date (YYYY-MM-DD)
The public key below is the matching counterpart to the private key in
license_server/private_key.pem. Replace the placeholder with the real key
after running license_server/generate_keys.py for the first time.
Public key replacement steps:
1. cd license_server && python generate_keys.py
2. Copy the full contents of license_server/public_key.pem
3. Replace the PUBLIC_KEY_PEM constant below with the copied text
4. Restart the TechDesk app
The public key can only verify signatures it cannot forge them.
It is safe to embed in the application and ship to customers.
"""
import base64
import json
import logging
from datetime import date, datetime
logger = logging.getLogger(__name__)
# ── Replace this with the output of license_server/generate_keys.py ──────────
# After running generate_keys.py, copy the full contents of public_key.pem here.
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
REPLACE_WITH_REAL_PUBLIC_KEY_FROM_license_server/generate_keys.py
-----END PUBLIC KEY-----"""
# ── Tier → feature set mapping ───────────────────────────────────────────────
TIER_FEATURES: dict[str, set] = {
'community' : set(),
'business' : {'chatbot', 'sla', 'digest', 'watchers', 'time_tracking', 'surveys'},
'enterprise': {'chatbot', 'sla', 'digest', 'watchers', 'time_tracking', 'surveys',
'email_ingestion'},
}
_COMMUNITY_STATUS = {
'valid' : False,
'tier' : 'community',
'customer' : None,
'email' : None,
'issued_at' : None,
'expires_at': None,
'days_left' : None,
}
def validate_license(key_string: str) -> dict:
"""Verify an RSA-signed TechDesk license key and return a status dict.
Returns a dict with keys:
valid bool
tier str ('community' | 'business' | 'enterprise')
customer str | None
email str | None
issued_at str | None (YYYY-MM-DD)
expires_at str | None (YYYY-MM-DD)
days_left int | None (None when invalid/no key)
reason str (human-readable; present when valid=False)
"""
if not key_string or not key_string.strip():
return {**_COMMUNITY_STATUS, 'reason': 'no_key'}
key_string = key_string.strip()
if not key_string.startswith('TDESK-'):
return {**_COMMUNITY_STATUS, 'reason': 'invalid_format'}
# Check if the public key is still a placeholder
if 'REPLACE_WITH_REAL_PUBLIC_KEY' in PUBLIC_KEY_PEM:
logger.warning('[LICENSE] Public key is a placeholder — all keys will fail validation. '
'Run license_server/generate_keys.py and update PUBLIC_KEY_PEM.')
return {**_COMMUNITY_STATUS, 'reason': 'no_public_key'}
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
raw = key_string[len('TDESK-'):]
if '.' not in raw:
return {**_COMMUNITY_STATUS, 'reason': 'invalid_format'}
payload_b64, sig_b64 = raw.split('.', 1)
payload_bytes = base64.urlsafe_b64decode(_pad_b64(payload_b64))
signature = base64.urlsafe_b64decode(_pad_b64(sig_b64))
public_key = serialization.load_pem_public_key(PUBLIC_KEY_PEM.strip().encode())
public_key.verify(signature, payload_bytes, padding.PKCS1v15(), hashes.SHA256())
payload = json.loads(payload_bytes.decode('utf-8'))
expires_at = payload.get('expires_at', '')
expires = date.fromisoformat(expires_at)
today = date.today()
if expires < today:
return {
**_COMMUNITY_STATUS,
'expires_at': expires_at,
'customer' : payload.get('customer'),
'email' : payload.get('email'),
'issued_at' : payload.get('issued_at'),
'reason' : 'expired',
}
days_left = (expires - today).days
tier = payload.get('tier', 'community')
if tier not in TIER_FEATURES:
tier = 'community'
logger.info(
f'[LICENSE] Valid {tier} license for {payload.get("customer")} '
f'(expires {expires_at}, {days_left} days left)'
)
return {
'valid' : True,
'tier' : tier,
'customer' : payload.get('customer'),
'email' : payload.get('email'),
'issued_at' : payload.get('issued_at'),
'expires_at': expires_at,
'days_left' : days_left,
}
except InvalidSignature:
logger.warning('[LICENSE] Key signature verification failed — key may be tampered.')
return {**_COMMUNITY_STATUS, 'reason': 'invalid_signature'}
except Exception as exc:
logger.warning(f'[LICENSE] Key validation error: {exc}')
return {**_COMMUNITY_STATUS, 'reason': 'invalid_format'}
def get_status() -> dict:
"""Return the cached license status stored in Flask app.config.
Must be called within an active Flask application context.
Falls back to community status if not set (e.g. during testing).
"""
from flask import current_app
return current_app.config.get('LICENSE_STATUS', {**_COMMUNITY_STATUS, 'reason': 'no_key'})
def feature_enabled(feature_name: str) -> bool:
"""Return True if the current license tier grants access to feature_name.
Must be called within an active Flask application context.
"""
status = get_status()
tier = status.get('tier', 'community')
return feature_name in TIER_FEATURES.get(tier, set())
def days_until_expiry() -> 'int | None':
"""Return days remaining on the current license, or None if invalid/community."""
status = get_status()
if not status.get('valid'):
return None
return status.get('days_left')
def _pad_b64(s: str) -> str:
"""Add base64url padding so Python's decoder accepts it."""
return s + '=' * (-len(s) % 4)
+4
View File
@@ -587,6 +587,10 @@ def send_weekly_digest(app):
from app.models import Ticket, TicketStatus, TicketPriority
with app.app_context():
from app.services.license_service import feature_enabled
if not feature_enabled('digest'):
logger.debug('[DIGEST] Skipped — digest feature not enabled on current license tier.')
return
try:
now = datetime.utcnow()
week_ago = now - timedelta(days=7)
+4
View File
@@ -143,6 +143,10 @@ def check_sla_breaches(app):
is re-opened, ensuring fresh notifications if the issue resurfaces.
"""
with app.app_context():
from app.services.license_service import feature_enabled
if not feature_enabled('sla'):
logger.debug('[SLA] Skipped — SLA feature not enabled on current license tier.')
return
try:
_run_sla_check(app)
except Exception as exc:
+151
View File
@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}License{% endblock %}
{% block page_title %}License{% endblock %}
{% block content %}
<div class="row g-4">
{# ── Status card ─────────────────────────────────────────────────────────── #}
<div class="col-12 col-lg-5">
<div class="card h-100">
<div class="card-header"><i class="bi bi-key me-2"></i>License Status</div>
<div class="card-body">
{# Tier badge #}
{% if status.tier == 'enterprise' %}
<span class="badge bg-purple fs-6 mb-3" style="background:#6d28d9!important;">
<i class="bi bi-award me-1"></i>Enterprise
</span>
{% elif status.tier == 'business' %}
<span class="badge bg-primary fs-6 mb-3">
<i class="bi bi-briefcase me-1"></i>Business
</span>
{% else %}
<span class="badge bg-secondary fs-6 mb-3">
<i class="bi bi-person me-1"></i>Community
</span>
{% endif %}
{% if status.valid %}
<p class="mb-1"><i class="bi bi-check-circle-fill text-success me-2"></i>
<strong>License is active</strong></p>
{% if status.customer %}
<p class="mb-1 text-muted small">
<i class="bi bi-building me-1"></i>{{ status.customer }}
</p>
{% endif %}
{% if status.email %}
<p class="mb-1 text-muted small">
<i class="bi bi-envelope me-1"></i>{{ status.email }}
</p>
{% endif %}
{% if status.issued_at %}
<p class="mb-1 text-muted small">
<i class="bi bi-calendar-check me-1"></i>Issued: {{ status.issued_at }}
</p>
{% endif %}
{% if status.expires_at %}
<p class="mb-1 text-muted small">
<i class="bi bi-calendar-x me-1"></i>Expires: {{ status.expires_at }}
{% if days_left is not none %}
{% if days_left < 30 %}
<span class="badge bg-warning text-dark ms-1">{{ days_left }}d left</span>
{% else %}
<span class="badge bg-success ms-1">{{ days_left }}d left</span>
{% endif %}
{% endif %}
</p>
{% endif %}
{% else %}
<p class="mb-2">
<i class="bi bi-x-circle-fill text-danger me-2"></i>
<strong>No active license</strong>
</p>
<p class="text-muted small mb-0">
{% if status.get('reason') == 'expired' %}
Your license expired on <strong>{{ status.expires_at }}</strong>.
Contact your vendor to renew.
{% elif status.get('reason') == 'no_key' %}
No license key is configured.
Add <code>LICENSE_KEY=TDESK-...</code> to your <code>.env</code> file and restart.
{% elif status.get('reason') == 'no_public_key' %}
The application's public key has not been configured yet.
Contact your system administrator.
{% else %}
The license key is invalid or has been tampered with.
Contact your vendor for a replacement key.
{% endif %}
</p>
{% endif %}
</div>
</div>
</div>
{# ── Feature checklist ───────────────────────────────────────────────────── #}
<div class="col-12 col-lg-7">
<div class="card h-100">
<div class="card-header"><i class="bi bi-grid-3x3-gap me-2"></i>Feature Access</div>
<div class="card-body">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Feature</th>
<th class="text-center">Community</th>
<th class="text-center">Business</th>
<th class="text-center">Enterprise</th>
</tr>
</thead>
<tbody>
{% set rows = [
('Core ticketing, comments, attachments', True, True, True),
('Knowledge base', True, True, True),
('User management, bulk actions, CSV export', True, True, True),
('AI Chatbot (Groq)', False, True, True),
('SLA tracking &amp; breach notifications', False, True, True),
('Weekly IT digest email', False, True, True),
('Ticket watchers', False, True, True),
('Time tracking', False, True, True),
('Satisfaction surveys', False, True, True),
('Email ingestion (IMAP)', False, False, True),
] %}
{% for label, com, biz, ent in rows %}
<tr>
<td style="font-size:.9rem;">{{ label | safe }}</td>
{% for flag, tier_name in [(com, 'community'), (biz, 'business'), (ent, 'enterprise')] %}
<td class="text-center">
{% set is_current = (status.tier == tier_name) %}
{% if flag %}
<i class="bi bi-check-circle-fill {% if is_current %}text-success{% else %}text-muted{% endif %}"></i>
{% else %}
<i class="bi bi-dash-circle text-muted opacity-25"></i>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── Setup instructions ──────────────────────────────────────────────────── #}
<div class="col-12">
<div class="card">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>How to Apply a License Key</div>
<div class="card-body">
<ol class="mb-0" style="font-size:.9rem;line-height:2;">
<li>Obtain a license key from your vendor (format: <code>TDESK-...</code>).</li>
<li>Open the <code>.env</code> file in your TechDesk installation directory.</li>
<li>Add or update the line: <code>LICENSE_KEY=TDESK-your-key-here</code></li>
<li>Restart the TechDesk service: <code>sudo systemctl restart gunicorn</code></li>
<li>Return to this page to confirm the license is active.</li>
</ol>
</div>
</div>
</div>
</div>
{% endblock %}
+33 -1
View File
@@ -1162,6 +1162,10 @@
<li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}">
<i class="bi bi-gear"></i> Settings
</a></li>
<li><a href="{{ url_for('admin.license_page') }}" class="{{ 'active' if request.endpoint == 'admin.license_page' }}">
<i class="bi bi-key"></i> License
{% if not license.valid %}<span class="badge bg-warning text-dark ms-1" style="font-size:.65rem;">!</span>{% endif %}
</a></li>
{% endif %}
</ul>
{% endif %}
@@ -1232,6 +1236,32 @@
</div>
{% endfor %}
{% endwith %}
{% if current_user.is_authenticated and current_user.is_admin %}
{% if not license.valid %}
<div class="alert alert-warning alert-dismissible mb-3" role="alert">
<i class="bi bi-key me-2"></i>
{% if license.get('reason') == 'expired' %}
Your TechDesk license expired on <strong>{{ license.expires_at }}</strong>.
Features are restricted to Community tier.
{% elif license.get('reason') == 'no_key' %}
No license key configured. TechDesk is running in <strong>Community mode</strong>.
{% else %}
License key is invalid. TechDesk is running in <strong>Community mode</strong>.
{% endif %}
<a href="{{ url_for('admin.license_page') }}" class="alert-link ms-1">View license details →</a>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% elif license.days_left is not none and license.days_left < 30 %}
<div class="alert alert-info alert-dismissible mb-3" role="alert">
<i class="bi bi-key me-2"></i>
Your TechDesk license expires in <strong>{{ license.days_left }} day{{ 's' if license.days_left != 1 }}</strong>.
<a href="{{ url_for('admin.license_page') }}" class="alert-link ms-1">View license →</a>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
{% endif %}
{% block content %}{% endblock %}
</div>
</div>
@@ -1251,7 +1281,8 @@
</div>
</div>
<!-- ── Chat FAB ── -->
<!-- ── Chat FAB (Business+ license required) ── -->
{% if license.tier in ('business', 'enterprise') %}
<div id="chat-fab" onclick="toggleChat()" title="IT Assistant">
<i class="bi bi-robot"></i>
</div>
@@ -1276,6 +1307,7 @@
<button class="chat-send" onclick="sendChat()"><i class="bi bi-send-fill"></i></button>
</div>
</div>
{% endif %}
{% else %}
<!-- Not authenticated — minimal layout -->
+5 -3
View File
@@ -828,7 +828,8 @@ function buildCommentEl(c) {
</div>
</div>
<!-- Watch / Unwatch -->
<!-- Watch / Unwatch (Business+ license required) -->
{% if license.tier in ('business', 'enterprise') %}
<div class="card mb-3">
<div class="card-body py-2 px-3 d-flex align-items-center justify-content-between">
<span style="font-size:13px;color:var(--muted);">
@@ -843,9 +844,10 @@ function buildCommentEl(c) {
</button>
</div>
</div>
{% endif %}
<!-- Time Tracking (IT staff only) -->
{% if current_user.is_it_staff %}
<!-- Time Tracking (IT staff only, Business+ license required) -->
{% if current_user.is_it_staff and license.tier in ('business', 'enterprise') %}
<div class="card mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-clock me-2"></i>Time Logged</span>
+5
View File
@@ -74,6 +74,11 @@ class Config:
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'admin@yourdomain.com')
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'Admin@123!')
# License
# Set LICENSE_KEY in .env to the TDESK-... key issued by the license server.
# If absent the app runs in Community (free) mode with reduced features.
LICENSE_KEY = os.environ.get('LICENSE_KEY', '')
class DevelopmentConfig(Config):
DEBUG = True
+342
View File
@@ -0,0 +1,342 @@
"""
TechDesk License Server
=======================
A simple, password-protected internal Flask app for generating and tracking
TechDesk license keys.
Setup
-----
1. Run generate_keys.py once to produce private_key.pem and public_key.pem.
2. Set environment variables (or edit the constants below):
LICENSE_SERVER_PASSWORD admin password (default: change-me)
SECRET_KEY Flask secret key for sessions
3. pip install -r requirements.txt
4. python app.py
This app is intended for localhost / internal network use only.
Do NOT expose it to the public internet.
License key format
------------------
TDESK-<payload_b64>.<signature_b64>
Where:
payload_b64 = base64url( JSON payload bytes )
signature_b64 = base64url( RSA-SHA256 signature of payload bytes )
JSON payload fields:
customer company / customer name
email admin contact email
tier "community" | "business" | "enterprise"
issued_at ISO date string (YYYY-MM-DD)
expires_at ISO date string (YYYY-MM-DD)
"""
import os
import json
import base64
import sqlite3
from datetime import date, timedelta
from functools import wraps
from flask import Flask, request, render_template_string, redirect, url_for, session, flash
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', 'license-server-secret-change-me')
ADMIN_PASSWORD = os.environ.get('LICENSE_SERVER_PASSWORD', 'change-me')
PRIVATE_KEY_FILE = os.path.join(os.path.dirname(__file__), 'private_key.pem')
DB_FILE = os.path.join(os.path.dirname(__file__), 'licenses.db')
TIERS = ['community', 'business', 'enterprise']
# ── Database ──────────────────────────────────────────────────────────────────
def get_db():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with get_db() as conn:
conn.execute('''
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer TEXT NOT NULL,
email TEXT NOT NULL,
tier TEXT NOT NULL,
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
key_preview TEXT NOT NULL,
full_key TEXT NOT NULL
)
''')
# ── RSA helpers ───────────────────────────────────────────────────────────────
def _load_private_key():
if not os.path.exists(PRIVATE_KEY_FILE):
raise FileNotFoundError(
f'private_key.pem not found at {PRIVATE_KEY_FILE}. '
'Run generate_keys.py first.'
)
with open(PRIVATE_KEY_FILE, 'rb') as f:
return serialization.load_pem_private_key(f.read(), password=None)
def generate_license_key(customer, email, tier, expires_at):
payload = {
'customer' : customer,
'email' : email,
'tier' : tier,
'issued_at' : date.today().isoformat(),
'expires_at': expires_at,
}
payload_bytes = json.dumps(payload, separators=(',', ':')).encode('utf-8')
private_key = _load_private_key()
signature = private_key.sign(payload_bytes, padding.PKCS1v15(), hashes.SHA256())
payload_b64 = base64.urlsafe_b64encode(payload_bytes).decode()
sig_b64 = base64.urlsafe_b64encode(signature).decode()
return f'TDESK-{payload_b64}.{sig_b64}'
# ── Auth ──────────────────────────────────────────────────────────────────────
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('authenticated'):
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated
# ── Templates ─────────────────────────────────────────────────────────────────
_BASE = '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>TechDesk License Server</title>
<style>
body { font-family: system-ui, sans-serif; background: #f5f5f5; margin: 0; padding: 0; }
.header { background: #1e3a5f; color: #fff; padding: 16px 32px; }
.header h1 { margin: 0; font-size: 1.4rem; }
.container { max-width: 1000px; margin: 32px auto; padding: 0 16px; }
.card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.1); padding: 24px; margin-bottom: 24px; }
label { display: block; margin-bottom: 4px; font-weight: 600; font-size: .9rem; color: #555; }
input, select { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px;
font-size: .95rem; box-sizing: border-box; margin-bottom: 14px; }
.btn { background: #1e3a5f; color: #fff; border: none; padding: 10px 20px;
border-radius: 6px; cursor: pointer; font-size: .95rem; }
.btn:hover { background: #163055; }
.btn-danger { background: #dc2626; }
.alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; }
.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.key-box { background: #f0f4f8; border: 1px solid #cbd5e0; border-radius: 6px;
padding: 12px; font-family: monospace; word-break: break-all;
font-size: .85rem; margin-top: 8px; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th { background: #f1f5f9; padding: 10px 12px; text-align: left; color: #475569; }
td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: .8rem; font-weight: 600; }
.badge-community { background: #e2e8f0; color: #475569; }
.badge-business { background: #dbeafe; color: #1d4ed8; }
.badge-enterprise { background: #ede9fe; color: #6d28d9; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
</style>
</head>
<body>
<div class="header"><h1>🔑 TechDesk License Server</h1></div>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>
</html>
'''
_LOGIN = '''
{% extends base %}
{% block content %}
<div class="card" style="max-width:400px;margin:0 auto;">
<h2 style="margin-top:0;">Login</h2>
<form method="POST">
<label>Password</label>
<input type="password" name="password" autofocus required>
<button type="submit" class="btn">Login</button>
</form>
</div>
{% endblock %}
'''
_INDEX = '''
{% extends base %}
{% block content %}
<div class="card">
<h2 style="margin-top:0;">Generate New License Key</h2>
<form method="POST" action="/generate">
<div class="form-row">
<div>
<label>Customer / Company Name</label>
<input type="text" name="customer" required placeholder="Acme Corp">
</div>
<div>
<label>Contact Email</label>
<input type="email" name="email" required placeholder="admin@acme.com">
</div>
</div>
<div class="form-row">
<div>
<label>Tier</label>
<select name="tier">
<option value="business">Business</option>
<option value="enterprise">Enterprise</option>
<option value="community">Community (free)</option>
</select>
</div>
<div>
<label>Duration</label>
<select name="duration">
<option value="365">1 year</option>
<option value="730">2 years</option>
<option value="180">6 months</option>
<option value="36500">Lifetime (100 years)</option>
</select>
</div>
</div>
<button type="submit" class="btn">Generate Key</button>
</form>
</div>
{% if new_key %}
<div class="card" style="border: 2px solid #3b82f6;">
<h3 style="margin-top:0;color:#1d4ed8;"> New License Key Generated</h3>
<p style="margin:0 0 8px;color:#555;">Copy this key and send it to the customer.
It will not be shown again in full.</p>
<div class="key-box">{{ new_key }}</div>
</div>
{% endif %}
<div class="card">
<h2 style="margin-top:0;">Issued Licenses ({{ licenses|length }})</h2>
{% if licenses %}
<table>
<thead>
<tr>
<th>#</th><th>Customer</th><th>Email</th><th>Tier</th>
<th>Issued</th><th>Expires</th><th>Key Preview</th>
</tr>
</thead>
<tbody>
{% for lic in licenses %}
<tr>
<td>{{ lic.id }}</td>
<td>{{ lic.customer }}</td>
<td>{{ lic.email }}</td>
<td><span class="badge badge-{{ lic.tier }}">{{ lic.tier }}</span></td>
<td>{{ lic.issued_at }}</td>
<td>{{ lic.expires_at }}</td>
<td style="font-family:monospace;font-size:.8rem;">{{ lic.key_preview }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;">No licenses issued yet.</p>
{% endif %}
</div>
<p style="text-align:right;">
<a href="/logout" style="color:#dc2626;font-size:.9rem;">Logout</a>
</p>
{% endblock %}
'''
# ── Routes ────────────────────────────────────────────────────────────────────
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
if request.form.get('password') == ADMIN_PASSWORD:
session['authenticated'] = True
return redirect(url_for('index'))
flash('Incorrect password.', 'error')
return render_template_string(_LOGIN, base=_BASE)
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
@app.route('/')
@login_required
def index():
with get_db() as conn:
licenses = conn.execute(
'SELECT * FROM licenses ORDER BY id DESC'
).fetchall()
return render_template_string(_INDEX, base=_BASE, licenses=licenses, new_key=None)
@app.route('/generate', methods=['POST'])
@login_required
def generate():
customer = request.form.get('customer', '').strip()
email = request.form.get('email', '').strip()
tier = request.form.get('tier', 'business')
duration = int(request.form.get('duration', 365))
if not customer or not email:
flash('Customer name and email are required.', 'error')
return redirect(url_for('index'))
if tier not in TIERS:
flash('Invalid tier.', 'error')
return redirect(url_for('index'))
expires_at = (date.today() + timedelta(days=duration)).isoformat()
try:
key = generate_license_key(customer, email, tier, expires_at)
except FileNotFoundError as exc:
flash(str(exc), 'error')
return redirect(url_for('index'))
key_preview = key[:32]
with get_db() as conn:
conn.execute(
'INSERT INTO licenses (customer, email, tier, issued_at, expires_at, key_preview, full_key) '
'VALUES (?, ?, ?, ?, ?, ?, ?)',
(customer, email, tier, date.today().isoformat(), expires_at, key_preview, key),
)
with get_db() as conn:
licenses = conn.execute(
'SELECT * FROM licenses ORDER BY id DESC'
).fetchall()
flash(f'License key generated for {customer}.', 'success')
return render_template_string(_INDEX, base=_BASE, licenses=licenses, new_key=key)
if __name__ == '__main__':
init_db()
print('TechDesk License Server running at http://127.0.0.1:5001')
print(f'Admin password: {ADMIN_PASSWORD}')
app.run(host='127.0.0.1', port=5001, debug=False)
+73
View File
@@ -0,0 +1,73 @@
"""
generate_keys.py One-time RSA-2048 key pair generator for TechDesk licensing.
Run this ONCE on your server to produce the key pair:
python generate_keys.py
Output:
private_key.pem Keep secret. Never ship to customers.
public_key.pem Paste the contents into license_service.py in TechDesk.
Security notes:
- private_key.pem must NEVER be committed to git or shipped in any customer build.
- public_key.pem is safe to embed in TechDesk it can only verify signatures,
not forge them.
- Store private_key.pem on a secure, offline or access-restricted machine.
"""
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import os
KEY_SIZE = 2048
PRIVATE_KEY_FILE = 'private_key.pem'
PUBLIC_KEY_FILE = 'public_key.pem'
def generate():
if os.path.exists(PRIVATE_KEY_FILE) or os.path.exists(PUBLIC_KEY_FILE):
print('Key files already exist. Delete them manually before regenerating.')
print(f' {PRIVATE_KEY_FILE}')
print(f' {PUBLIC_KEY_FILE}')
return
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=KEY_SIZE,
)
# Write private key (unencrypted PEM — restrict file permissions after writing)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
with open(PRIVATE_KEY_FILE, 'wb') as f:
f.write(private_pem)
# Write public key (PEM)
public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
with open(PUBLIC_KEY_FILE, 'wb') as f:
f.write(public_pem)
# Restrict private key permissions on Unix-like systems
try:
os.chmod(PRIVATE_KEY_FILE, 0o600)
except AttributeError:
pass # Windows — manage permissions manually
print('Key pair generated successfully.')
print(f' Private key : {PRIVATE_KEY_FILE} (KEEP SECRET)')
print(f' Public key : {PUBLIC_KEY_FILE} (safe to embed in TechDesk)')
print()
print('Next steps:')
print(' 1. Copy the contents of public_key.pem into license_service.py (PUBLIC_KEY_PEM constant).')
print(' 2. Never commit or share private_key.pem.')
if __name__ == '__main__':
generate()
+2
View File
@@ -0,0 +1,2 @@
flask==3.0.3
cryptography==42.0.8