// 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] 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 } } }