343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""
|
|
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)
|