fase 3.5: licenciamiento por plan (clave firmada, planes basico/profesional/enterprise/custom, modulos incluidos y limites de usuarios/sucursales/citas/almacenamiento, modo aviso o bloqueo, panel de licencia) + API keys gestionables con alcances y revocacion (arregla la API publica que estaba rota) + webhooks salientes firmados con outbox, reintentos y bitacora; herramientas de vendedor y docs
This commit is contained in:
219
infra/licencias/generar_licencia.py
Executable file
219
infra/licencias/generar_licencia.py
Executable file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Generador de claves de licencia SKEEN (herramienta del vendedor).
|
||||
|
||||
Vive FUERA del producto: no se despliega en las instancias de las clínicas.
|
||||
Solo el vendedor corre este script para emitir/verificar una clave. La clave se
|
||||
firma con HMAC-SHA256 usando la clave del vendedor; la instancia valida la firma
|
||||
con la misma clave (symmetric HMAC), por lo que la clave del vendedor debe estar
|
||||
configurada en cada instancia que aplique licencias:
|
||||
|
||||
1) ir.config_parameter 'skeen.licencia.vendor_key'
|
||||
2) variable de entorno SKEEN_LICENCIA_VENDOR_KEY
|
||||
3) archivo /root/.skeen-licencias/vendor.key (permisos 600)
|
||||
|
||||
Formato de la clave (texto, una sola línea):
|
||||
|
||||
SKN1.<base64url(payload_json)>.<base64url(hmac_sha256(payload_b64, vendor))>
|
||||
|
||||
`payload_json` = JSON con claves ordenadas y separadores mínimos:
|
||||
{\"cliente\":...,\"limites\":{...},\"modulos\":[...],\"plan\":...,\"vence\":...}
|
||||
|
||||
Uso:
|
||||
generar_licencia.py generar --cliente "Spa Bella" --plan profesional \
|
||||
--vence 2027-12-31 [--modulos whatsapp,recursos] \
|
||||
[--usuarios-max N --sucursales-max N --citas-mes-max N --almacenamiento-mb N]
|
||||
generar_licencia.py verificar --clave "SKN1...."
|
||||
|
||||
El plan define los módulos por default; --modulos los pisa. En los planes
|
||||
`basico`/`profesional`/`enterprise` la lista de módulos es explícita; `custom`
|
||||
usa exactamente la lista indicada (vacía = todos los módulos).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
PREFIJO = 'SKN1'
|
||||
VENDOR_KEY_ENV = 'SKEEN_LICENCIA_VENDOR_KEY'
|
||||
VENDOR_KEY_FILE = '/root/.skeen-licencias/vendor.key'
|
||||
|
||||
MODULOS_BASE = ['agenda', 'pacientes', 'pos', 'inventario', 'reportes']
|
||||
MODULOS_GATEABLES = ['whatsapp', 'notificaciones', 'reservas_online', 'recursos',
|
||||
'membresias', 'compras', 'comisiones', 'formularios']
|
||||
MODULOS_VALIDOS = MODULOS_BASE + MODULOS_GATEABLES
|
||||
|
||||
PLANES = {
|
||||
'basico': MODULOS_BASE,
|
||||
'profesional': MODULOS_BASE + ['whatsapp', 'notificaciones',
|
||||
'reservas_online', 'recursos'],
|
||||
'enterprise': MODULOS_BASE + MODULOS_GATEABLES,
|
||||
'custom': [], # lo que diga --modulos; vacío = todos
|
||||
}
|
||||
|
||||
|
||||
def cargar_vendor(secreto=None, archivo=None):
|
||||
if secreto:
|
||||
return secreto.strip()
|
||||
env = (os.environ.get(VENDOR_KEY_ENV) or '').strip()
|
||||
if env:
|
||||
return env
|
||||
ruta = archivo or VENDOR_KEY_FILE
|
||||
try:
|
||||
with open(ruta, 'r', encoding='utf-8') as fh:
|
||||
return (fh.read() or '').strip()
|
||||
except OSError:
|
||||
return ''
|
||||
|
||||
|
||||
def _b64url(raw):
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
def _b64url_decode(texto):
|
||||
return base64.urlsafe_b64decode(texto + '=' * (-len(texto) % 4))
|
||||
|
||||
|
||||
def _payload_canonico(payload):
|
||||
return json.dumps(payload, sort_keys=True, separators=(',', ':'),
|
||||
ensure_ascii=False).encode('utf-8')
|
||||
|
||||
|
||||
def firmar(payload, vendor):
|
||||
cuerpo = _b64url(_payload_canonico(payload))
|
||||
firma = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
|
||||
hashlib.sha256).digest()
|
||||
return '%s.%s.%s' % (PREFIJO, cuerpo, _b64url(firma))
|
||||
|
||||
|
||||
def verificar(clave, vendor):
|
||||
partes = (clave or '').strip().split('.')
|
||||
if len(partes) != 3 or partes[0] != PREFIJO:
|
||||
return None, 'Formato de clave inválido.'
|
||||
_, cuerpo, firma = partes
|
||||
esperada = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
|
||||
hashlib.sha256).digest()
|
||||
try:
|
||||
recibida = _b64url_decode(firma)
|
||||
except Exception:
|
||||
return None, 'Firma corrupta.'
|
||||
if not hmac.compare_digest(esperada, recibida):
|
||||
return None, 'La firma no es válida (clave manipulada).'
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(cuerpo).decode('utf-8'))
|
||||
except Exception:
|
||||
return None, 'Contenido corrupto.'
|
||||
vence = str(payload.get('vence') or '').strip()
|
||||
if vence:
|
||||
try:
|
||||
if date.fromisoformat(vence) < date.today():
|
||||
return payload, 'La licencia está vencida (venció el %s).' % vence
|
||||
except ValueError:
|
||||
return payload, 'Fecha de vencimiento inválida.'
|
||||
return payload, None
|
||||
|
||||
|
||||
def cmd_generar(args):
|
||||
vendor = cargar_vendor(args.secreto, args.secreto_archivo)
|
||||
if not vendor:
|
||||
print('ERROR: no hay clave de vendedor. Configura %s o el archivo %s.'
|
||||
% (VENDOR_KEY_ENV, VENDOR_KEY_FILE), file=sys.stderr)
|
||||
return 2
|
||||
plan = (args.plan or 'custom').strip().lower()
|
||||
if plan not in PLANES:
|
||||
print('ERROR: plan inválido (basico|profesional|enterprise|custom).',
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if args.modulos:
|
||||
modulos = []
|
||||
for m in args.modulos.split(','):
|
||||
m = m.strip().lower()
|
||||
if m not in MODULOS_VALIDOS:
|
||||
print('ERROR: módulo desconocido: %s' % m, file=sys.stderr)
|
||||
return 2
|
||||
if m not in modulos:
|
||||
modulos.append(m)
|
||||
else:
|
||||
modulos = list(PLANES[plan])
|
||||
limites = {
|
||||
'usuarios_max': args.usuarios_max or 0,
|
||||
'sucursales_max': args.sucursales_max or 0,
|
||||
'citas_mes_max': args.citas_mes_max or 0,
|
||||
'almacenamiento_mb': args.almacenamiento_mb or 0,
|
||||
}
|
||||
payload = {
|
||||
'v': 1,
|
||||
'cliente': args.cliente or '',
|
||||
'plan': plan,
|
||||
'vence': (args.vence or '').strip(),
|
||||
'emitida': date.today().isoformat(),
|
||||
'modulos': modulos,
|
||||
'limites': limites,
|
||||
}
|
||||
clave = firmar(payload, vendor)
|
||||
if args.json:
|
||||
print(json.dumps({'clave': clave, 'payload': payload},
|
||||
ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(clave)
|
||||
print('', file=sys.stderr)
|
||||
print('Cliente: %s | Plan: %s | Vence: %s'
|
||||
% (payload['cliente'] or '-', plan, payload['vence'] or 'sin vencimiento'),
|
||||
file=sys.stderr)
|
||||
print('Módulos: %s' % (', '.join(modulos) or 'todos'), file=sys.stderr)
|
||||
print('Límites: %s' % limites, file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verificar(args):
|
||||
vendor = cargar_vendor(args.secreto, args.secreto_archivo)
|
||||
if not vendor:
|
||||
print('ERROR: no hay clave de vendedor.', file=sys.stderr)
|
||||
return 2
|
||||
payload, error = verificar(args.clave, vendor)
|
||||
if args.json:
|
||||
print(json.dumps({'valida': error is None, 'error': error,
|
||||
'payload': payload}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print('VÁLIDA' if error is None else 'INVÁLIDA: %s' % error)
|
||||
if payload:
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if error is None else 1
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Generador de licencias SKEEN')
|
||||
parser.add_argument('--secreto', help='clave del vendedor (o env/archivo)')
|
||||
parser.add_argument('--secreto-archivo', dest='secreto_archivo',
|
||||
help='ruta alterna del archivo con la clave del vendedor')
|
||||
sub = parser.add_subparsers(dest='cmd', required=True)
|
||||
|
||||
g = sub.add_parser('generar', help='emite una clave de licencia')
|
||||
g.add_argument('--cliente', default='')
|
||||
g.add_argument('--plan', default='custom',
|
||||
help='basico | profesional | enterprise | custom')
|
||||
g.add_argument('--vence', default='', help='YYYY-MM-DD (vacío = sin vencimiento)')
|
||||
g.add_argument('--modulos', default='', help='lista separada por comas')
|
||||
g.add_argument('--usuarios-max', dest='usuarios_max', type=int, default=0)
|
||||
g.add_argument('--sucursales-max', dest='sucursales_max', type=int, default=0)
|
||||
g.add_argument('--citas-mes-max', dest='citas_mes_max', type=int, default=0)
|
||||
g.add_argument('--almacenamiento-mb', dest='almacenamiento_mb', type=int, default=0)
|
||||
g.add_argument('--json', action='store_true')
|
||||
g.set_defaults(func=cmd_generar)
|
||||
|
||||
v = sub.add_parser('verificar', help='verifica una clave de licencia')
|
||||
v.add_argument('--clave', required=True)
|
||||
v.add_argument('--json', action='store_true')
|
||||
v.set_defaults(func=cmd_verificar)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user