ENRE_WEB/gestor/main.go
SITO 7d5dbeec60 Cuenta de externas, arreglos de seguridad, títulos arriba y despliegue
- Rol «externas» para la asamblea de externas: pide sin cupo, siempre con el
  colectivo de fuera y su contacto, y sus solicitudes salen en naranja hasta
  que la asamblea de la Enre las confirma.
- /pedir/ explica en una línea que los colectivos externos piden fecha en la
  asamblea de externas, los lunes de 19:00 a 20:00.
- Seguridad: argon2 limitado a dos cálculos a la vez y el intento se cuenta
  antes de calcular (60 accesos simultáneos llevaban el proceso a 3,8 GB);
  bloqueo por cuenta a 20 fallos para que no se pueda dejar fuera a la
  asamblea; avisos por sesión en vez de por URL; sig sin «..»; eventos de
  31 días como mucho; parámetros de argon2 acotados.
- Títulos pegados arriba en todas las páginas; la franja de la enredadera
  recupera ese alto para seguir llenando la pantalla.
- despliegue/: instalar.sh, cuenta.sh, servicio de systemd con el proceso
  encerrado, fragmento de nginx que además deja de servir .git y las notas
  internas, y configuración de ejemplo. Ninguna contraseña en el repositorio.
2026-09-23 12:15:33 +02:00

363 lines
9.4 KiB
Go

// enrecal: reservas de fechas del CSO La Enredadera de Tetuán.
//
// Sirve la sección /pedir/ de laenre.net: el calendario de fechas pedidas y
// confirmadas, el formulario para pedirlas y el panel de la asamblea. Sin una
// línea de JavaScript en el navegador.
package main
import (
"context"
"errors"
"flag"
"fmt"
"html/template"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
)
type App struct {
cfg Config
alm Almacen
ses *Sesiones
cer *Cerrojo // por IP
cerU *Cerrojo // por cuenta
auth *AuthLog
// mu serializa las escrituras de eventos: comprobar las reglas y guardar
// tienen que ser una sola operacion, o dos colectivos podrian colarse en la
// misma fecha a la vez.
mu sync.Mutex
plantillas *template.Template
}
func main() {
log.SetFlags(0)
log.SetPrefix("enrecal: ")
var (
rutaConfig = flag.String("config", "", "fichero de configuracion en JSON")
escucha = flag.String("escucha", "", "direccion donde escuchar, por ejemplo 127.0.0.1:8090")
dirDatos = flag.String("datos", "", "directorio del registro")
dirPlant = flag.String("plantillas", "", "directorio de plantillas")
dirEstat = flag.String("estatico", "", "raiz de la web, para el modo local")
logAuth = flag.String("logauth", "", "registro de accesos; \"-\" para sacarlo por stderr")
insegura = flag.Bool("insegura", false, "cookie sin marca Secure, solo para probar sin TLS")
local = flag.Bool("local", false, "sirve tambien la web estatica, solo para probar sin nginx")
usuario = flag.String("usuario", "", "usuario, para las ordenes cuenta y clave")
nombre = flag.String("nombre", "", "nombre visible del colectivo, para la orden cuenta")
rol = flag.String("rol", string(RolInterno), "rol de la cuenta: enre, interno o externas")
claveFija = flag.String("clave", "", "clave temporal para cuenta y clave; sin ella se genera una al azar")
definitiva = flag.Bool("definitiva", false, "con -clave: la clave no caduca al entrar (minimo 12 caracteres)")
)
flag.Usage = uso
flag.Parse()
cfg, err := CargarConfig(*rutaConfig)
if err != nil {
log.Fatal(err)
}
for destino, valor := range map[*string]string{
&cfg.Escucha: *escucha, &cfg.DirDatos: *dirDatos,
&cfg.DirPlantillas: *dirPlant, &cfg.DirEstatico: *dirEstat,
} {
if valor != "" {
*destino = valor
}
}
switch *logAuth {
case "":
case "-":
cfg.LogAuth = "" // a stderr, para probar en local
default:
cfg.LogAuth = *logAuth
}
if *insegura {
cfg.CookieSegura = false
}
if *local {
cfg.Local = true
}
if err := cfg.Validar(); err != nil {
log.Fatal(err)
}
app, err := NuevaApp(cfg)
if err != nil {
log.Fatal(err)
}
defer app.Cerrar()
orden := flag.Arg(0)
if orden == "" {
orden = "servir"
}
switch orden {
case "servir":
if err := app.Servir(); err != nil {
log.Fatal(err)
}
case "cuenta":
if err := app.AltaCuenta(*usuario, *nombre, Rol(*rol), *claveFija, *definitiva); err != nil {
log.Fatal(err)
}
case "clave":
if err := app.ResetClave(*usuario, *claveFija, *definitiva); err != nil {
log.Fatal(err)
}
case "caducar":
n, err := app.Caducar()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d solicitudes caducadas\n", n)
default:
uso()
os.Exit(2)
}
}
func uso() {
fmt.Fprint(os.Stderr, `uso: enrecal [opciones] <orden>
ordenes:
servir levanta la seccion /pedir/ (por defecto)
cuenta da de alta una cuenta e imprime su clave temporal (-clave para fijarla)
enrecal cuenta -usuario hacklab -nombre "Hacklab La Raiz" -rol interno
clave resetea la clave de una cuenta e imprime la nueva temporal
caducar pasa a caducadas las solicitudes que toque y sale
opciones:
`)
flag.PrintDefaults()
}
func NuevaApp(cfg Config) (*App, error) {
reg, err := AbrirRegistro(cfg.DirDatos)
if err != nil {
return nil, fmt.Errorf("abriendo el registro: %w", err)
}
al, err := AbrirAuthLog(cfg.LogAuth)
if err != nil {
return nil, fmt.Errorf("abriendo el registro de accesos: %w", err)
}
patron := filepath.Join(cfg.DirPlantillas, "*.tmpl.html")
plantillas, err := template.New("pedir").ParseGlob(patron)
if err != nil {
return nil, fmt.Errorf("plantillas (%s): %w", patron, err)
}
return &App{
cfg: cfg,
alm: reg,
ses: NuevasSesiones(inactividadSesion),
cer: NuevoCerrojo(maxIntentos, ventanaIntentos, esperaBloqueo),
cerU: NuevoCerrojo(maxIntentosCuenta, ventanaIntentos, esperaBloqueo),
auth: al,
plantillas: plantillas,
}, nil
}
func (a *App) Cerrar() {
if r, ok := a.alm.(*Registro); ok {
r.Cerrar()
}
a.auth.Cerrar()
}
// nombres asocia cada usuario con el nombre visible de su colectivo.
func (a *App) nombres() map[string]string {
out := map[string]string{}
for _, c := range a.alm.Cuentas() {
out[c.Usuario] = c.Nombre
}
return out
}
func (a *App) roles() map[string]Rol {
out := map[string]Rol{}
for _, c := range a.alm.Cuentas() {
out[c.Usuario] = c.Rol
}
return out
}
func (a *App) vista() Vista {
return Vista{
roles: a.roles(),
reglas: a.cfg.Reglas,
exc: a.alm.Excepciones(),
eventos: a.alm.Eventos(),
nombres: a.nombres(),
hoy: DiaDe(time.Now()),
}
}
// claveTemporal devuelve la clave pedida o, si no se pide ninguna, una al azar.
// Sea cual sea, la cuenta queda marcada como temporal: al entrar por primera
// vez no deja hacer nada hasta cambiarla por una de 12 caracteres o más.
func claveTemporal(fija string) string {
if fija != "" {
return fija
}
return ClaveTemporal()
}
func avisoClave(clave string, definitiva bool) {
if definitiva {
fmt.Println("clave definitiva: no se pide cambiarla al entrar")
return
}
fmt.Printf("clave temporal: %s\nSe entrega en mano y hay que cambiarla al entrar por primera vez.\n", clave)
}
// claveDefinitiva comprueba que una clave que no se va a cambiar al entrar
// cumple lo mismo que se exige al cambiarla desde la web.
func claveDefinitiva(fija string) error {
if fija == "" {
return errors.New("-definitiva necesita -clave")
}
if len([]rune(fija)) < 12 {
return errors.New("una clave definitiva tiene al menos 12 caracteres")
}
return nil
}
func (a *App) AltaCuenta(usuario, nombre string, rol Rol, fija string, definitiva bool) error {
if err := ValidarUsuario(usuario); err != nil {
return err
}
if nombre == "" {
return errors.New("falta -nombre: el nombre visible del colectivo")
}
if !rol.Valido() {
return fmt.Errorf("el rol solo puede ser %q, %q o %q", RolEnre, RolInterno, RolExternas)
}
if _, existe := a.alm.BuscarCuenta(usuario); existe {
return fmt.Errorf("la cuenta %q ya existe; para cambiarle la clave usa la orden clave", usuario)
}
if definitiva {
if err := claveDefinitiva(fija); err != nil {
return err
}
}
clave := claveTemporal(fija)
c := Cuenta{
Usuario: usuario, Nombre: nombre, Rol: rol,
Hash: HashClave(clave), Temporal: !definitiva, Activa: true, Creada: time.Now(),
}
if err := a.alm.GuardarCuenta("consola", c); err != nil {
return err
}
fmt.Printf("cuenta %q (%s) creada con rol %s\n", usuario, nombre, rol)
avisoClave(clave, definitiva)
return nil
}
func (a *App) ResetClave(usuario, fija string, definitiva bool) error {
c, ok := a.alm.BuscarCuenta(usuario)
if !ok {
return fmt.Errorf("no existe la cuenta %q", usuario)
}
if definitiva {
if err := claveDefinitiva(fija); err != nil {
return err
}
}
clave := claveTemporal(fija)
c.Hash = HashClave(clave)
c.Temporal = !definitiva
if err := a.alm.GuardarCuenta("consola", c); err != nil {
return err
}
a.ses.BorrarDeUsuario(usuario)
fmt.Printf("clave de %q reseteada\n", usuario)
avisoClave(clave, definitiva)
return nil
}
// Caducar pasa a caducadas las solicitudes que se han quedado sin margen o sin
// respuesta, liberando las fechas.
func (a *App) Caducar() (int, error) {
a.mu.Lock()
defer a.mu.Unlock()
ahora := time.Now()
n := 0
for _, e := range a.alm.Eventos() {
motivo := a.cfg.Reglas.Caducidad(e, ahora)
if motivo == "" {
continue
}
e.Estado = Caducado
e.Resuelto = ahora
e.ResueltoPor = "automatico"
e.Motivo = motivo
if err := a.alm.GuardarEvento("automatico", e); err != nil {
return n, err
}
log.Printf("caducada %q de %s: %s", e.Titulo, e.Solicitante, motivo)
n++
}
return n, nil
}
func (a *App) Servir() error {
if _, err := a.Caducar(); err != nil {
return err
}
srv := &http.Server{
Addr: a.cfg.Escucha,
Handler: a.rutas(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 * 1024,
}
paro := make(chan os.Signal, 1)
signal.Notify(paro, syscall.SIGINT, syscall.SIGTERM)
go a.tareasPeriodicas(paro)
errores := make(chan error, 1)
go func() {
log.Printf("escuchando en %s", a.cfg.Escucha)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errores <- err
}
}()
select {
case err := <-errores:
return err
case <-paro:
log.Print("parando")
ctx, cancelar := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelar()
return srv.Shutdown(ctx)
}
}
// tareasPeriodicas repasa cada hora las solicitudes caducables y limpia las
// sesiones abandonadas.
func (a *App) tareasPeriodicas(paro <-chan os.Signal) {
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
select {
case <-t.C:
if _, err := a.Caducar(); err != nil {
log.Printf("repaso de caducidades: %v", err)
}
a.ses.Limpiar()
case <-paro:
return
}
}
}