Adds SETUP-LICENSE-SERVER.md — end-to-end install runbook for the license server on the existing invixiom box (Ubuntu 24.04). Covers DNS, system packages, Postgres + API in Docker, dedicated system user, secrets layout under /srv/datatools-license/secrets (mode 400), nginx config in a separate sites-available/unalogix file, Let's Encrypt cert issuance, smoke tests, backups, monitoring, key rotation, and rollback. Multi-tenancy is explicit at every layer: separate DNS zone (unalogix.com vs invixiom.com), separate nginx file, separate TLS cert, dedicated backend ports (8090 for the API, 5433 for Postgres, both localhost-only), separate docker compose project and volume. No invixiom service is touched. LICENSE-SERVER.md updated: hosting choice moved from "Fly.io / Render" (rejected) to self-hosted (decided). Points at the new runbook for ops specifics. ADMIN.md pointer table updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
270 lines
8.0 KiB
Markdown
270 lines
8.0 KiB
Markdown
# ADMIN — Internal license operations
|
|
|
|
Creator/operator-only reference. End users should read `USER-GUIDE.md` instead.
|
|
|
|
This doc covers everything the creator does that buyers never see: generating
|
|
the signing keypair, minting license blobs, the dev vs. production key story,
|
|
and how to recover from key loss.
|
|
|
|
---
|
|
|
|
## TL;DR — I just need a license for my dev machine
|
|
|
|
You're running from source, so the repo's embedded dev keypair signs and
|
|
verifies. No env vars needed.
|
|
|
|
```bash
|
|
python scripts/generate_license.py \
|
|
--name "Michael Dombaugh" \
|
|
--email michael.dombaugh@gmail.com \
|
|
--tier core
|
|
```
|
|
|
|
Copy the `DTLIC2:…` blob from stdout, then activate:
|
|
|
|
```bash
|
|
python -m src.license_cli activate "DTLIC2:..." \
|
|
--name "Michael Dombaugh" \
|
|
--email michael.dombaugh@gmail.com
|
|
```
|
|
|
|
Verify:
|
|
|
|
```bash
|
|
python -m src.license_cli status
|
|
```
|
|
|
|
License lands at `~/.datatools/license.json`, valid 1 year.
|
|
|
|
> The `--name` / `--email` you pass to `activate` **must** match the values
|
|
> the blob was minted with — they're part of the signed payload.
|
|
|
|
---
|
|
|
|
## Key model (Ed25519, asymmetric)
|
|
|
|
| Key | Lives where | Used for |
|
|
|-----|------------|---------|
|
|
| **Private** (32 bytes hex) | Creator's password manager / KMS only | Signing license blobs |
|
|
| **Public** (32 bytes hex) | Baked into the shipped binary | Verifying blobs at activation |
|
|
|
|
The split is the whole point: an attacker with a copy of the binary still
|
|
can't mint blobs — they'd need the private key, which never ships.
|
|
|
|
There's also an in-tree **dev keypair** (`src/license/_dev_keypair.py`)
|
|
derived deterministically from a seed. It's used when no env vars are set,
|
|
so devs/tests can sign and verify locally without juggling secrets. Frozen
|
|
builds that still use it are rejected at startup by
|
|
`assert_production_safe` — see `src/license/crypto.py:84`.
|
|
|
|
Blob format prefix: `DTLIC2:` (v1 was HMAC; v2 is Ed25519).
|
|
|
|
---
|
|
|
|
## One-time setup — generating the production keypair
|
|
|
|
Run once, before the first paid release.
|
|
|
|
```bash
|
|
python scripts/generate_keypair.py --output keypair.env
|
|
```
|
|
|
|
You'll get:
|
|
|
|
```
|
|
DATATOOLS_LICENSE_PRIVKEY=<64 hex chars> # KEEP SECRET
|
|
DATATOOLS_LICENSE_PUBKEY=<64 hex chars> # BAKE INTO BUILD
|
|
```
|
|
|
|
Then:
|
|
|
|
1. **Stash the private key** in a password manager / KMS / hardware token.
|
|
Losing it means no more renewals — see "Recovery" below.
|
|
2. **Delete `keypair.env`** from disk once stored.
|
|
3. **Set the public key** as `DATATOOLS_LICENSE_PUBKEY` in the PyInstaller
|
|
build environment. The shipped binary embeds it via the env at freeze time.
|
|
|
|
---
|
|
|
|
## Minting a buyer license (production)
|
|
|
|
With the production private key loaded:
|
|
|
|
```bash
|
|
export DATATOOLS_LICENSE_PRIVKEY=<your-private-hex>
|
|
|
|
python scripts/generate_license.py \
|
|
--name "Buyer Name" \
|
|
--email buyer@example.com \
|
|
--tier core \
|
|
--years 1 \
|
|
--output buyer.dtlic
|
|
```
|
|
|
|
Flags:
|
|
|
|
| Flag | Default | Notes |
|
|
|------|---------|-------|
|
|
| `--name` | required | Buyer's full name. Goes into signed payload. |
|
|
| `--email` | required | Buyer's email. Goes into signed payload. |
|
|
| `--tier` | `core` | One of: `lite`, `core`, `pro` |
|
|
| `--years` | `1` | Lifetime in years |
|
|
| `--key` | random | Override the auto-generated license key |
|
|
| `--output` / `-o` | stdout | Write blob to file instead of printing |
|
|
|
|
Deliver the blob to the buyer either inline in the purchase email or as
|
|
the attached `.dtlic` file.
|
|
|
|
---
|
|
|
|
## Tiers
|
|
|
|
| Tier | Features |
|
|
|------|---------|
|
|
| **lite** | Deduplicator, Text Cleaner, Format Standardizer |
|
|
| **core** | All 9 tools |
|
|
| **pro** | All 9 tools + future Pro-only features |
|
|
|
|
Source of truth: `src/license/features.py::all_features_for_tier`.
|
|
|
|
---
|
|
|
|
## Useful one-liners
|
|
|
|
Mint a free internal/team license (dev key, no env needed):
|
|
|
|
```bash
|
|
python scripts/generate_license.py --name "QA Bot" --email qa@datatools.app --tier core --years 5
|
|
```
|
|
|
|
Mint with a stable, human-readable key:
|
|
|
|
```bash
|
|
python scripts/generate_license.py --name "Acme Corp" --email ops@acme.com \
|
|
--tier pro --key "DT1-PRO-ACME-2026"
|
|
```
|
|
|
|
Renew an existing buyer (just re-mint with the same email; they paste the
|
|
new blob):
|
|
|
|
```bash
|
|
python -m src.license_cli renew "DTLIC2:..."
|
|
```
|
|
|
|
Check what's active locally:
|
|
|
|
```bash
|
|
python -m src.license_cli status
|
|
```
|
|
|
|
Wipe a local license (move to a new machine, debug a buyer issue):
|
|
|
|
```bash
|
|
python -m src.license_cli deactivate
|
|
```
|
|
|
|
---
|
|
|
|
## Customer record-keeping — the issuance log
|
|
|
|
Every successful `scripts/generate_license.py` run appends one JSON
|
|
line to a local **issuance log**. This is the creator-side system of
|
|
record for "who has a license" until the server-side flow in
|
|
`docs/LICENSE-SERVER.md` lands.
|
|
|
|
**Path:** `~/.datatools-creator/issued.jsonl` (override with
|
|
`$DATATOOLS_ISSUANCE_LOG`). Mode 600. Outside the buyer-facing
|
|
`~/.datatools/` dir so it never gets bundled into a shipped install.
|
|
|
|
**Format** — one record per line:
|
|
|
|
```json
|
|
{
|
|
"license_key": "DT1-CORE-5dd8e1db-d90c4656",
|
|
"name": "Michael Dombaugh",
|
|
"email": "michael.dombaugh@gmail.com",
|
|
"tier": "core",
|
|
"issued_at": "2026-05-13T22:10:27Z",
|
|
"expires_at": "2031-05-13T22:10:27Z",
|
|
"blob": "DTLIC2:..."
|
|
}
|
|
```
|
|
|
|
The full blob is stored so you can re-deliver to a buyer who lost
|
|
their email without re-minting (the re-minted blob would have a
|
|
different signature and would invalidate any device they'd already
|
|
activated against the old one).
|
|
|
|
**Useful operations:**
|
|
|
|
```bash
|
|
# Full list of issued licenses
|
|
cat ~/.datatools-creator/issued.jsonl | jq
|
|
|
|
# Find by buyer email
|
|
jq -r 'select(.email == "buyer@example.com")' ~/.datatools-creator/issued.jsonl
|
|
|
|
# Count by tier
|
|
jq -r .tier ~/.datatools-creator/issued.jsonl | sort | uniq -c
|
|
|
|
# Licenses expiring in the next 30 days
|
|
jq -r 'select(.expires_at < "'"$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"'") | .email' \
|
|
~/.datatools-creator/issued.jsonl
|
|
|
|
# Re-deliver a buyer's blob
|
|
jq -r 'select(.email == "buyer@example.com") | .blob' \
|
|
~/.datatools-creator/issued.jsonl
|
|
```
|
|
|
|
**Skipping the log** for test mints: pass `--no-log`. Never use this
|
|
for real buyer fulfillment — an unlogged mint is invisible to every
|
|
future query and to the eventual server-side migration.
|
|
|
|
**Backup:** treat this file like a small business ledger. Copy it
|
|
into your password manager / encrypted cloud sync alongside the
|
|
private key. Losing it doesn't break anything cryptographically (you
|
|
can still mint new licenses) but it does lose the customer list.
|
|
|
|
**Migrating to the server:** the JSONL schema is intentionally close
|
|
to the planned `licenses` table in `docs/LICENSE-SERVER.md`. Once the
|
|
server is up, a one-shot import script will read the JSONL and
|
|
insert each row.
|
|
|
|
---
|
|
|
|
## Recovery — what if the private key is lost?
|
|
|
|
Existing licenses keep working until they expire (the public key in the
|
|
shipped binary still verifies them). What breaks:
|
|
|
|
- **Renewals** — you can't mint a new blob for an existing buyer.
|
|
- **New sales** — you can't mint anything.
|
|
|
|
Path forward:
|
|
|
|
1. Generate a new keypair (`scripts/generate_keypair.py`).
|
|
2. Ship a new build with the new public key.
|
|
3. Re-issue every active buyer a new blob signed by the new private key.
|
|
4. Communicate the upgrade path to buyers.
|
|
|
|
Treat the private key like a code-signing cert — back it up to two
|
|
independent secure locations.
|
|
|
|
---
|
|
|
|
## Files & code pointers
|
|
|
|
| Path | Purpose |
|
|
|------|---------|
|
|
| `scripts/generate_keypair.py` | One-time keypair generation |
|
|
| `scripts/generate_license.py` | Mint a signed blob |
|
|
| `src/license/crypto.py` | Sign / verify / dev-key detection |
|
|
| `src/license/_dev_keypair.py` | In-tree dev keypair (never ships in prod) |
|
|
| `src/license/manager.py` | `assert_production_safe` startup check |
|
|
| `src/license/features.py` | Tier → features mapping |
|
|
| `src/license_cli.py` | End-user `activate` / `status` / `renew` / `deactivate` |
|
|
| `~/.datatools/license.json` | Where activated licenses are stored on each machine |
|
|
| `~/.datatools-creator/issued.jsonl` | Creator-side issuance log (one JSON line per mint) |
|
|
| `docs/LICENSE-SERVER.md` | Design for the future online issuance + record-keeping system |
|
|
| `docs/SETUP-LICENSE-SERVER.md` | Self-hosted server install runbook (DNS, Docker, nginx, TLS, backups) |
|