220 lines
8.1 KiB
Python
Executable File
220 lines
8.1 KiB
Python
Executable File
#!/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())
|