05/22 Implement license version
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.2–4.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*
|
||||
+43
-7
@@ -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,7 +266,13 @@ def _start_background_schedulers(app):
|
||||
|
||||
scheduler = BackgroundScheduler(daemon=True)
|
||||
|
||||
# SLA breach check — every 30 minutes
|
||||
_lic = app.config.get('LICENSE_STATUS', {})
|
||||
_lic_tier = _lic.get('tier', 'community')
|
||||
_is_business = _lic_tier in ('business', 'enterprise')
|
||||
_is_enterprise = _lic_tier == 'enterprise'
|
||||
|
||||
# SLA breach check — Business+ only, every 30 minutes
|
||||
if _is_business:
|
||||
scheduler.add_job(
|
||||
func = check_sla_breaches,
|
||||
trigger = IntervalTrigger(minutes=30),
|
||||
@@ -251,7 +282,8 @@ def _start_background_schedulers(app):
|
||||
args = [app],
|
||||
)
|
||||
|
||||
# Inbound email ingestion — interval from SystemSetting
|
||||
# Inbound email ingestion — Enterprise only
|
||||
if _is_enterprise:
|
||||
scheduler.add_job(
|
||||
func = check_inbound_email,
|
||||
trigger = IntervalTrigger(minutes=email_interval),
|
||||
@@ -261,7 +293,8 @@ def _start_background_schedulers(app):
|
||||
args = [app],
|
||||
)
|
||||
|
||||
# Weekly digest — every Monday at 08:00 UTC
|
||||
# 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),
|
||||
@@ -272,9 +305,12 @@ def _start_background_schedulers(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}')
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 & 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
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
flask==3.0.3
|
||||
cryptography==42.0.8
|
||||
Reference in New Issue
Block a user