diff --git a/Claude.md b/Claude.md index 1ca3107..2104137 100644 --- a/Claude.md +++ b/Claude.md @@ -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` = `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 diff --git a/LICENSING_GUIDE.md b/LICENSING_GUIDE.md new file mode 100644 index 0000000..2a31c67 --- /dev/null +++ b/LICENSING_GUIDE.md @@ -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* diff --git a/app/__init__.py b/app/__init__.py index fa83798..cbf79b5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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}') diff --git a/app/routes/admin.py b/app/routes/admin.py index b88b191..98c688f 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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, + ) diff --git a/app/routes/chatbot.py b/app/routes/chatbot.py index 39d5d34..f5e25b5 100644 --- a/app/routes/chatbot.py +++ b/app/routes/chatbot.py @@ -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() diff --git a/app/routes/tickets.py b/app/routes/tickets.py index 8f259af..cf391d3 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -999,6 +999,10 @@ def ticket_survey(token): @tickets_bp.route('/tickets//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//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//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) diff --git a/app/services/license_service.py b/app/services/license_service.py new file mode 100644 index 0000000..b4e02ad --- /dev/null +++ b/app/services/license_service.py @@ -0,0 +1,177 @@ +""" +TechDesk License Service — offline RSA-signed key validation. + +Key format: TDESK-. + + 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) diff --git a/app/services/notification_service.py b/app/services/notification_service.py index 05d1dcc..f928223 100644 --- a/app/services/notification_service.py +++ b/app/services/notification_service.py @@ -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) diff --git a/app/services/sla_service.py b/app/services/sla_service.py index c097107..406a56c 100644 --- a/app/services/sla_service.py +++ b/app/services/sla_service.py @@ -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: diff --git a/app/templates/admin/license.html b/app/templates/admin/license.html new file mode 100644 index 0000000..e8f01f1 --- /dev/null +++ b/app/templates/admin/license.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} +{% block title %}License{% endblock %} +{% block page_title %}License{% endblock %} + +{% block content %} +
+ + {# ── Status card ─────────────────────────────────────────────────────────── #} +
+
+
License Status
+
+ + {# Tier badge #} + {% if status.tier == 'enterprise' %} + + Enterprise + + {% elif status.tier == 'business' %} + + Business + + {% else %} + + Community + + {% endif %} + + {% if status.valid %} +

+ License is active

+ {% if status.customer %} +

+ {{ status.customer }} +

+ {% endif %} + {% if status.email %} +

+ {{ status.email }} +

+ {% endif %} + {% if status.issued_at %} +

+ Issued: {{ status.issued_at }} +

+ {% endif %} + {% if status.expires_at %} +

+ Expires: {{ status.expires_at }} + {% if days_left is not none %} + {% if days_left < 30 %} + {{ days_left }}d left + {% else %} + {{ days_left }}d left + {% endif %} + {% endif %} +

+ {% endif %} + + {% else %} +

+ + No active license +

+

+ {% if status.get('reason') == 'expired' %} + Your license expired on {{ status.expires_at }}. + Contact your vendor to renew. + {% elif status.get('reason') == 'no_key' %} + No license key is configured. + Add LICENSE_KEY=TDESK-... to your .env 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 %} +

+ {% endif %} + +
+
+
+ + {# ── Feature checklist ───────────────────────────────────────────────────── #} +
+
+
Feature Access
+
+ + + + + + + + + + + {% 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 %} + + + {% for flag, tier_name in [(com, 'community'), (biz, 'business'), (ent, 'enterprise')] %} + + {% endfor %} + + {% endfor %} + +
FeatureCommunityBusinessEnterprise
{{ label | safe }} + {% set is_current = (status.tier == tier_name) %} + {% if flag %} + + {% else %} + + {% endif %} +
+
+
+
+ + {# ── Setup instructions ──────────────────────────────────────────────────── #} +
+
+
How to Apply a License Key
+
+
    +
  1. Obtain a license key from your vendor (format: TDESK-...).
  2. +
  3. Open the .env file in your TechDesk installation directory.
  4. +
  5. Add or update the line: LICENSE_KEY=TDESK-your-key-here
  6. +
  7. Restart the TechDesk service: sudo systemctl restart gunicorn
  8. +
  9. Return to this page to confirm the license is active.
  10. +
+
+
+
+ +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 1bebb63..68aed66 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1162,6 +1162,10 @@
  • Settings
  • +
  • + License + {% if not license.valid %}!{% endif %} +
  • {% endif %} {% endif %} @@ -1232,6 +1236,32 @@ {% endfor %} {% endwith %} + + {% if current_user.is_authenticated and current_user.is_admin %} + {% if not license.valid %} + + {% elif license.days_left is not none and license.days_left < 30 %} + + {% endif %} + {% endif %} + {% block content %}{% endblock %} @@ -1251,7 +1281,8 @@ - + + {% if license.tier in ('business', 'enterprise') %}
    @@ -1276,6 +1307,7 @@ + {% endif %} {% else %} diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index 48af95f..9afa1db 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -828,7 +828,8 @@ function buildCommentEl(c) { - + + {% if license.tier in ('business', 'enterprise') %}
    @@ -843,9 +844,10 @@ function buildCommentEl(c) {
    + {% endif %} - - {% if current_user.is_it_staff %} + + {% if current_user.is_it_staff and license.tier in ('business', 'enterprise') %}
    Time Logged diff --git a/config/config.py b/config/config.py index 7f7efb0..24a03c6 100644 --- a/config/config.py +++ b/config/config.py @@ -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 diff --git a/license_server/app.py b/license_server/app.py new file mode 100644 index 0000000..10531f9 --- /dev/null +++ b/license_server/app.py @@ -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-. + +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 = ''' + + + + + TechDesk License Server + + + +

    🔑 TechDesk License Server

    +
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
    {{ msg }}
    + {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
    + + +''' + +_LOGIN = ''' +{% extends base %} +{% block content %} +
    +

    Login

    +
    + + + +
    +
    +{% endblock %} +''' + +_INDEX = ''' +{% extends base %} +{% block content %} +
    +

    Generate New License Key

    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +{% if new_key %} +
    +

    ✅ New License Key Generated

    +

    Copy this key and send it to the customer. + It will not be shown again in full.

    +
    {{ new_key }}
    +
    +{% endif %} + +
    +

    Issued Licenses ({{ licenses|length }})

    + {% if licenses %} + + + + + + + + + {% for lic in licenses %} + + + + + + + + + + {% endfor %} + +
    #CustomerEmailTierIssuedExpiresKey Preview
    {{ lic.id }}{{ lic.customer }}{{ lic.email }}{{ lic.tier }}{{ lic.issued_at }}{{ lic.expires_at }}{{ lic.key_preview }}…
    + {% else %} +

    No licenses issued yet.

    + {% endif %} +
    + +

    + Logout +

    +{% 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) diff --git a/license_server/generate_keys.py b/license_server/generate_keys.py new file mode 100644 index 0000000..30e1ade --- /dev/null +++ b/license_server/generate_keys.py @@ -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() diff --git a/license_server/requirements.txt b/license_server/requirements.txt new file mode 100644 index 0000000..9d6ce20 --- /dev/null +++ b/license_server/requirements.txt @@ -0,0 +1,2 @@ +flask==3.0.3 +cryptography==42.0.8