commit 57d32d0b58dbc70d81836aa5d7cc5800bab3887a Author: handfly Date: Sat May 2 21:11:50 2026 -0400 Initial Commit diff --git a/.directory b/.directory new file mode 100644 index 0000000..9dd830b --- /dev/null +++ b/.directory @@ -0,0 +1,2 @@ +[Desktop Entry] +Icon=orange-folder-git diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..fdec8f1 --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,23 @@ +# Credits + +URIT BBS is a modern reimplementation inspired by **T.A.G.-BBS v1.03**, +originally written by **Patrick E. Hughes** in 1986-87 for the Commodore Amiga. + +The original T.A.G.-BBS was a single-user, dial-up bulletin board system that +ran on AmigaOS, communicating with callers over a serial modem connection. It +featured message boards, private mail, file libraries, bulletins, and an +integrated line editor — all in roughly 6,000 lines of C. + +URIT BBS carries the spirit of that software forward into the modern era, +replacing the Amiga serial port with TCP/IP (telnet and SSH), flat binary data +files with SQLite, and the single-caller limitation with multi-user support. + +## Special Thanks + +- **Patrick E. Hughes** — Original author of T.A.G.-BBS. The clean architecture + of the original code, particularly its clear separation between I/O and + application logic, made this reimplementation possible and informed much of + URIT's design. + +- All the sysops, users, and developers who built and sustained the BBS + community through the dial-up era and continue to keep it alive today. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..242472f --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# URIT BBS + +A modern bulletin board system inspired by T.A.G.-BBS (1986), written in Go. + +## Features + +- Telnet and SSH access +- Multi-user support +- Message boards with security-based access control +- Private mail +- File libraries +- ANSI terminal support +- SQLite storage (zero-configuration) +- Cross-platform: runs on x86, ARM64 (Raspberry Pi), and more + +## Building + +Requires Go 1.22 or later. + +```bash +go build -o urit ./cmd/urit/ +``` + +### Cross-compile for Raspberry Pi + +```bash +GOOS=linux GOARCH=arm64 go build -o urit ./cmd/urit/ +``` + +## Quick Start + +Initialize a new BBS instance: + +```bash +./urit init +``` + +This creates the database, a sysop account, starter boards, a welcome +bulletin, an empty file library, and sample ANSI screen files. It's the +modern equivalent of the original TAG-BBS's GENERATE program. + +Start the server: + +```bash +./urit +``` + +Connect with any telnet client: + +```bash +telnet localhost 2323 +``` + +## Usage + +```bash +./urit # Start the BBS server (default config: config.toml) +./urit -config PATH # Start with a specific config file +./urit init # Initialize a new BBS instance +./urit init -force # Re-initialize, overwriting existing database +./urit version # Print version and exit +./urit help # Show help +``` + +## Configuration + +See `config.toml` for all available settings. Running `urit init` will +create a default config file if one doesn't exist. + +## Project Status + +Under active development. + +## License + +This project is released into the public domain. See [LICENSE](LICENSE) for details. + +## Credits + +See [CREDITS.md](CREDITS.md) for acknowledgments. diff --git a/cmd/urit/data/urit.db b/cmd/urit/data/urit.db new file mode 100644 index 0000000..eb5a218 Binary files /dev/null and b/cmd/urit/data/urit.db differ diff --git a/cmd/urit/init.go b/cmd/urit/init.go new file mode 100644 index 0000000..798a2a3 --- /dev/null +++ b/cmd/urit/init.go @@ -0,0 +1,462 @@ +package main + +// This file implements the "urit init" subcommand. +// +// It is the modern equivalent of GENERATE.C from the original TAG-BBS. +// The original was a standalone program that read configuration from +// text files (s:Tag_System, s:Tag_Boards, etc.) and pre-allocated +// fixed-size binary data files (User.Data, Board.Keys, Board.Data, +// Mail.Keys, Mail.Data, Library.Keys). +// +// Our version is simpler because SQLite handles storage — we just need +// to create the database, populate it with starter content, and set up +// the screen files directory. The schema is created automatically by +// store.OpenSQLite's migrate() call, so init focuses on seed data. + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/urit/urit/internal/auth" + "github.com/urit/urit/internal/config" + "github.com/urit/urit/internal/models" + "github.com/urit/urit/internal/store" +) + +// runInit handles the "urit init" subcommand. +func runInit(args []string) { + // Parse init-specific flags. + configPath := "config.toml" + force := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "-config": + if i+1 < len(args) { + configPath = args[i+1] + i++ + } else { + fatal("error: -config requires a path") + } + case "-force": + force = true + case "-help", "--help", "-h": + fmt.Println("Usage: urit init [-config ] [-force]") + fmt.Println() + fmt.Println("Initialize a new URIT BBS instance.") + fmt.Println() + fmt.Println("This creates the database, sysop account, starter boards,") + fmt.Println("a welcome bulletin, an empty file library, and sample") + fmt.Println("screen files. Equivalent to GENERATE from the original") + fmt.Println("TAG-BBS.") + fmt.Println() + fmt.Println("Flags:") + fmt.Println(" -config Path to config file (default: config.toml)") + fmt.Println(" -force Overwrite existing database") + return + default: + fatal("unknown flag: %s", args[i]) + } + } + + fmt.Println() + fmt.Printf("URIT BBS v%s — System Initialization\n", version) + fmt.Println(strings.Repeat("─", 44)) + fmt.Println() + + // Load or create config. + cfg := loadOrCreateConfig(configPath) + + // Safety check: refuse to overwrite an existing database unless forced. + if !force { + if _, err := os.Stat(cfg.Storage.SQLitePath); err == nil { + fatal("Database already exists: %s\nUse -force to overwrite.", cfg.Storage.SQLitePath) + } + } else { + // Remove existing database files so we start clean. + os.Remove(cfg.Storage.SQLitePath) + os.Remove(cfg.Storage.SQLitePath + "-wal") + os.Remove(cfg.Storage.SQLitePath + "-shm") + } + + reader := bufio.NewReader(os.Stdin) + + // Prompt for sysop account details. + sysopName := promptLine(reader, "Sysop username", "Sysop") + sysopPass := promptPassword(reader) + + fmt.Println() + + // Create data directory. + dataDir := filepath.Dir(cfg.Storage.SQLitePath) + if err := os.MkdirAll(dataDir, 0755); err != nil { + fatal("Creating data directory: %v", err) + } + fmt.Printf(" Created directory: %s\n", dataDir) + + // Create screens directory. + if err := os.MkdirAll(cfg.System.Screens, 0755); err != nil { + fatal("Creating screens directory: %v", err) + } + fmt.Printf(" Created directory: %s\n", cfg.System.Screens) + + // Open database — migrate() creates the schema automatically. + // This replaces the flat-file creation loops in GENERATE.C that + // wrote empty User.Data, Board.Keys/Data, Mail.Keys/Data, etc. + db, err := store.OpenSQLite(cfg.Storage.SQLitePath) + if err != nil { + fatal("Creating database: %v", err) + } + defer db.Close() + fmt.Printf(" Created database: %s\n", cfg.Storage.SQLitePath) + + // --- Seed data --- + // The original GENERATE.C pre-allocated fixed-size files with empty + // template structs. We just insert actual starter records. + + fmt.Println() + fmt.Println("Populating database...") + fmt.Println() + + // 1. Sysop account — always user ID 1, SecStatus 255. + // In the original, the sysop was identified by a backdoor login + // or by slot position. Here, ID 1 is sysop by convention, and + // SecStatus 255 grants full access to everything. + hash, err := auth.HashPassword(sysopPass) + if err != nil { + fatal("Hashing sysop password: %v", err) + } + + now := time.Now() + sysop := &models.User{ + Name: sysopName, + PasswordHash: hash, + Comments: "System operator", + Active: true, + SecStatus: 255, + SecBoard: 255, + SecLibrary: 255, + SecBulletin: 255, + TimeLimit: 86400, // 24 hours — effectively unlimited + LastOn: &now, + } + if err := db.CreateUser(sysop); err != nil { + fatal("Creating sysop account: %v", err) + } + fmt.Printf(" Sysop account: %s (ID #%d)\n", sysop.Name, sysop.ID) + + // 2. Starter boards — mirrors the original's Tag_Boards config. + // "General" is the catch-all board, readable by everyone, writable + // by registered users. "Testing" is wide open for experimentation. + boards := []*models.Board{ + { + Name: "General", + ReadLow: 0, ReadHigh: 255, + WriteLow: 1, WriteHigh: 255, + MaxPosts: 200, + }, + { + Name: "Testing", + ReadLow: 0, ReadHigh: 255, + WriteLow: 0, WriteHigh: 255, + MaxPosts: 100, + }, + } + for _, b := range boards { + if err := db.CreateBoard(b); err != nil { + fatal("Creating board %q: %v", b.Name, err) + } + fmt.Printf(" Board: %s (ID #%d)\n", b.Name, b.ID) + } + + // 3. Welcome bulletin — points to a screen file we'll create below. + // In the original, bulletins were read from s:Tag_Bulletins config + // and each had a filename + location + read range. + welcomeBulletin := &models.Bulletin{ + Name: "Welcome", + FilePath: filepath.Join(cfg.System.Screens, "bulletin-welcome.ans"), + ReadLow: 0, + ReadHigh: 255, + } + if err := db.CreateBulletin(welcomeBulletin); err != nil { + fatal("Creating welcome bulletin: %v", err) + } + fmt.Printf(" Bulletin: %s (ID #%d)\n", welcomeBulletin.Name, welcomeBulletin.ID) + + // 4. Empty file library — like the original's Tag_Libraries config. + // Creates the library record and its on-disk directory. + libPath := filepath.Join(dataDir, "files") + if err := os.MkdirAll(libPath, 0755); err != nil { + fatal("Creating library directory: %v", err) + } + + library := &models.Library{ + Name: "Files", + FilePath: libPath, + UploadLow: 1, UploadHigh: 255, + DownloadLow: 0, DownloadHigh: 255, + MaxFiles: 200, + } + if err := db.CreateLibrary(library); err != nil { + fatal("Creating library: %v", err) + } + fmt.Printf(" Library: %s (ID #%d, path: %s)\n", library.Name, library.ID, libPath) + + // 5. Welcome mail to sysop — a small touch. The original didn't do + // this, but it gives the sysop something to see in the mail system + // immediately and confirms that mail works. + welcomeMail := &models.Mail{ + Title: "Welcome to URIT BBS", + Author: "URIT System", + FromID: sysop.ID, + ToID: sysop.ID, + Recipient: sysop.Name, + Body: "Congratulations on setting up your URIT BBS!\n\nYour system is ready to accept callers. You can customize\nthe screen files in the screens/ directory and adjust\nsettings in config.toml.\n\nHappy sysoping!", + } + if err := db.CreateMail(welcomeMail); err != nil { + // Non-fatal — mail is nice to have but not critical. + fmt.Printf(" Warning: could not create welcome mail: %v\n", err) + } else { + fmt.Printf(" Welcome mail: sent to %s\n", sysop.Name) + } + + // 6. Screen files — create sample ANSI files for every screen the + // BBS code references via SendFile(). These are plain-text placeholders + // that the sysop can replace with proper ANSI art later. + fmt.Println() + fmt.Println("Creating screen files...") + fmt.Println() + createScreenFiles(cfg.System.Screens, cfg.System.Name, sysop.Name) + + // Done! + fmt.Println() + fmt.Println(strings.Repeat("─", 44)) + fmt.Println("Initialization complete!") + fmt.Println() + fmt.Printf(" Config: %s\n", configPath) + fmt.Printf(" Database: %s\n", cfg.Storage.SQLitePath) + fmt.Printf(" Screens: %s\n", cfg.System.Screens) + fmt.Println() + fmt.Println("Start the BBS with:") + fmt.Printf(" urit -config %s\n", configPath) + fmt.Println() +} + +// loadOrCreateConfig loads config from path, or creates a default config +// file and returns defaults if it doesn't exist. +func loadOrCreateConfig(path string) *config.Config { + cfg, err := config.Load(path) + if err != nil { + fatal("Loading config: %v", err) + } + + // If the config file doesn't exist, write the default one. + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + if writeErr := writeDefaultConfig(path); writeErr != nil { + fmt.Printf(" Warning: could not write default config: %v\n", writeErr) + } else { + fmt.Printf(" Created config: %s\n", path) + } + } else { + fmt.Printf(" Using config: %s\n", path) + } + + return cfg +} + +// writeDefaultConfig writes a default config.toml file. +func writeDefaultConfig(path string) error { + content := `# URIT BBS Configuration + +[system] +name = "URIT BBS" +sysop = "Sysop" +location = "./data/" # Base directory for BBS data files +screens = "./screens/" # ANSI art and display files + +[telnet] +enabled = true +address = ":2323" # Listen address and port + +[ssh] +enabled = false +address = ":2222" +host_key = "./data/ssh_host_key" + +[http] +enabled = true +address = ":8080" # HTTP file server + +[storage] +driver = "sqlite" +sqlite_path = "./data/urit.db" + +[users] +max_accounts = 500 +guest_time_limit = 1800 # Seconds (30 minutes) +new_time_limit = 3600 # Seconds (1 hour) +valid_time_limit = 7200 # Seconds (2 hours) + +[users.guest_security] +status = 0 +board = 0 +library = 0 +bulletin = 0 + +[users.new_security] +status = 1 +board = 1 +library = 1 +bulletin = 1 + +[users.valid_security] +status = 2 +board = 2 +library = 2 +bulletin = 2 + +[logging] +level = "info" # debug, info, warn, error +file = "" # Empty means stdout only +` + return os.WriteFile(path, []byte(content), 0644) +} + +// createScreenFiles writes sample ANSI screen files for every screen +// the BBS references. These use basic ANSI color codes so they look +// reasonable in any terminal. The sysop can replace them with proper +// ANSI art later. +// +// In the original TAG-BBS, screen files were created manually by the +// sysop and referenced by the init config files. Here we generate +// sensible defaults automatically. +func createScreenFiles(screensDir, systemName, sysopName string) { + screens := map[string]string{ + "welcome.ans": ansiScreen( + "\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n"+ + "\033[1;37m Welcome to "+systemName+"!\033[0m\r\n"+ + "\033[36m Powered by URIT BBS\033[0m\r\n"+ + "\033[36m Operated by "+sysopName+"\033[0m\r\n"+ + "\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n", + ), + "login.ans": ansiScreen( + "\033[1;33m Log in with your username and password.\033[0m\r\n"+ + "\033[33m Enter \"guest\" to browse as a guest.\033[0m\r\n", + ), + "guest.ans": ansiScreen( + "\033[1;33m Welcome, guest!\033[0m\r\n"+ + "\033[33m Some features require a registered account.\033[0m\r\n"+ + "\033[33m Press [J] at the main menu to join.\033[0m\r\n", + ), + "newuser.ans": ansiScreen( + "\033[1;32m Welcome aboard!\033[0m\r\n"+ + "\033[32m Your account is new and may need sysop validation.\033[0m\r\n"+ + "\033[32m Enjoy your time on the board!\033[0m\r\n", + ), + "logon.ans": ansiScreen( + "\033[1;36m Welcome back!\033[0m\r\n", + ), + "notime.ans": ansiScreen( + "\033[1;31m You have no remaining time for today.\033[0m\r\n"+ + "\033[31m Please try again later.\033[0m\r\n", + ), + "mainmenu.ans": "", // Intentionally blank — the generated help list is shown instead. + "help.ans": "", // Intentionally blank — falls through to generated command list. + "join.ans": ansiScreen( + "\033[1;36m Create Your Account\033[0m\r\n"+ + "\033[1;36m"+strings.Repeat("─", 30)+"\033[0m\r\n", + ), + "joined.ans": ansiScreen( + "\033[1;32m You're in! Welcome to the community.\033[0m\r\n"+ + "\033[32m Check out the message boards and say hello.\033[0m\r\n", + ), + "goodbye.ans": ansiScreen( + "\033[1;33m Thanks for calling "+systemName+"!\033[0m\r\n"+ + "\033[33m See you next time.\033[0m\r\n", + ), + "bulletin-welcome.ans": ansiScreen( + "\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n"+ + "\033[1;37m Welcome Bulletin\033[0m\r\n"+ + "\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n"+ + "\r\n"+ + "\033[37m Welcome to "+systemName+"!\033[0m\r\n"+ + "\r\n"+ + "\033[37m This bulletin board system is running URIT BBS,\033[0m\r\n"+ + "\033[37m a modern reimplementation of T.A.G.-BBS (1986).\033[0m\r\n"+ + "\r\n"+ + "\033[37m Check out the message boards, file libraries,\033[0m\r\n"+ + "\033[37m and private mail system. If you're new here,\033[0m\r\n"+ + "\033[37m post an introduction on the General board!\033[0m\r\n"+ + "\r\n"+ + "\033[36m — "+sysopName+", Sysop\033[0m\r\n"+ + "\r\n"+ + "\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n", + ), + } + + for name, content := range screens { + path := filepath.Join(screensDir, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + fmt.Printf(" Warning: could not create %s: %v\n", name, err) + } else { + fmt.Printf(" Screen file: %s\n", name) + } + } +} + +// ansiScreen is a trivial helper that returns its input unchanged. +// It exists purely for readability in the screen definitions above. +func ansiScreen(content string) string { + return content +} + +// promptLine reads a line of input from the user with a default value. +func promptLine(reader *bufio.Reader, prompt, defaultVal string) string { + if defaultVal != "" { + fmt.Printf("%s [%s]: ", prompt, defaultVal) + } else { + fmt.Printf("%s: ", prompt) + } + + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return defaultVal + } + return line +} + +// promptPassword reads a password with confirmation. The password is +// visible on screen — this is acceptable for a local setup command. +func promptPassword(reader *bufio.Reader) string { + for { + fmt.Print("Sysop password: ") + pass1, _ := reader.ReadString('\n') + pass1 = strings.TrimSpace(pass1) + + if len(pass1) < 4 { + fmt.Println(" Password must be at least 4 characters.") + continue + } + + fmt.Print("Confirm password: ") + pass2, _ := reader.ReadString('\n') + pass2 = strings.TrimSpace(pass2) + + if pass1 != pass2 { + fmt.Println(" Passwords do not match. Try again.") + continue + } + + return pass1 + } +} + +// fatal prints an error message and exits. +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/urit/main.go b/cmd/urit/main.go new file mode 100644 index 0000000..97515e3 --- /dev/null +++ b/cmd/urit/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/urit/urit/internal/config" + "github.com/urit/urit/internal/server" + "github.com/urit/urit/internal/store" +) + +const version = "0.2.0" + +func main() { + // Subcommand routing. If the first argument is a known subcommand, + // dispatch to it. Otherwise, fall through to the default server mode. + // + // This replaces flag.Parse() at the top level — each subcommand + // (and the default server path) parses its own flags. + if len(os.Args) > 1 { + switch os.Args[1] { + case "init": + runInit(os.Args[2:]) + return + case "version", "--version", "-version": + fmt.Printf("URIT BBS v%s\n", version) + return + case "help", "--help", "-help", "-h": + printUsage() + return + } + } + + runServer(os.Args[1:]) +} + +// printUsage displays top-level help. +func printUsage() { + fmt.Printf("URIT BBS v%s\n\n", version) + fmt.Println("Usage:") + fmt.Println(" urit Start the BBS server") + fmt.Println(" urit init Initialize a new BBS instance") + fmt.Println(" urit version Print version and exit") + fmt.Println(" urit help Show this help") + fmt.Println() + fmt.Println("Server flags:") + fmt.Println(" -config Path to configuration file (default: config.toml)") +} + +// runServer starts the BBS server. This is the original main() logic, +// now behind a function so the subcommand router can call it. +func runServer(args []string) { + // Parse server-specific flags from the remaining args. + configPath := "config.toml" + for i := 0; i < len(args); i++ { + switch args[i] { + case "-config": + if i+1 < len(args) { + configPath = args[i+1] + i++ + } else { + fmt.Fprintf(os.Stderr, "error: -config requires a path\n") + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "unknown flag: %s\n", args[i]) + os.Exit(1) + } + } + + cfg, err := config.Load(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + log.Printf("URIT BBS v%s", version) + log.Printf("System: %s", cfg.System.Name) + log.Printf("Sysop: %s", cfg.System.Sysop) + log.Printf("Storage: %s (%s)", cfg.Storage.Driver, cfg.Storage.SQLitePath) + + // Open the database — replaces the original's file-based data stores + // (User.Data, System.Data, *.Keys, *.Data). + db, err := store.OpenSQLite(cfg.Storage.SQLitePath) + if err != nil { + log.Fatalf("Database error: %v", err) + } + defer db.Close() + log.Printf("Database open: %s", cfg.Storage.SQLitePath) + + srv := server.New(cfg, db) + + // Start the HTTP file server if enabled. + // This runs alongside telnet and serves the system info page, + // health endpoint, and (in later steps) library browsing/downloads. + var httpSrv *server.HTTPServer + if cfg.HTTP.Enabled { + httpSrv = server.NewHTTP(cfg, db, srv) + go func() { + if err := httpSrv.ListenAndServe(); err != nil { + log.Printf("HTTP server error: %v", err) + } + }() + } + + // Handle shutdown signals — close the listener cleanly so the + // process exits without leaving the port in TIME_WAIT. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + log.Printf("Received %v, shutting down...", sig) + if httpSrv != nil { + httpSrv.Close() + } + srv.Close() + }() + + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("Server error: %v", err) + } + + log.Printf("URIT BBS shut down.") +} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..0f7726f --- /dev/null +++ b/config.toml @@ -0,0 +1,48 @@ +# URIT BBS Configuration + +[system] +name = "URIT BBS" +sysop = "Sysop" +location = "./data/" # Base directory for BBS data files +screens = "./screens/" # ANSI art and display files + +[telnet] +enabled = true +address = ":2323" # Listen address and port + +[ssh] +enabled = false +address = ":2222" +host_key = "./data/ssh_host_key" + +[storage] +driver = "sqlite" +sqlite_path = "./data/urit.db" + +[users] +max_accounts = 500 +guest_time_limit = 1800 # Seconds (30 minutes) +new_time_limit = 3600 # Seconds (1 hour) +valid_time_limit = 7200 # Seconds (2 hours) + +[users.guest_security] +status = 0 +board = 0 +library = 0 +bulletin = 0 + +[users.new_security] +status = 1 +board = 1 +library = 1 +bulletin = 1 + +[users.valid_security] +status = 2 +board = 2 +library = 2 +bulletin = 2 + +[logging] +level = "info" # debug, info, warn, error +file = "" # Empty means stdout only diff --git a/docs/ROADMAP-v0.3.0.md b/docs/ROADMAP-v0.3.0.md new file mode 100644 index 0000000..a87cb94 --- /dev/null +++ b/docs/ROADMAP-v0.3.0.md @@ -0,0 +1,268 @@ +# URIT BBS — v0.3.0 Roadmap + +Status: **Planning** +Previous: v0.2.0 (steps 10a–17) — telnet, auth, message boards, mail, file libraries, +bulletins, HTTP file server, inter-node chat, sysop console, sysop guide. + +--- + +## Step 18 — SSH Support + +**Priority: 1 — High value, low effort** + +The config section (`[ssh]`) is already defined and parsed. The session layer +(`internal/session`) is transport-agnostic — it works on any `net.Conn`. Wiring +up SSH is primarily a listener and key-management task. + +### Sub-steps + +- **18a — SSH listener and host key management.** + Add an SSH listener to the server using `golang.org/x/crypto/ssh`. Generate a + host key on first run (or via `urit init`) and store it at the configured path. + Accept connections, negotiate a PTY channel, and hand the resulting `net.Conn` + equivalent to the existing `handleConnection` flow. + +- **18b — Password authentication callback.** + Wire the SSH password callback to the same `auth.Check` path used by telnet + login. On successful auth, skip the telnet login prompts and drop the user + directly into the post-login flow (logon.ans → main menu). Guest access via + SSH should be configurable (allow/deny in config). + +- **18c — Terminal handling.** + Read PTY dimensions from the SSH channel request (replaces telnet NAWS). Map + SSH window-change requests to the session's terminal size updates. Verify ANSI + escape passthrough works correctly over the SSH channel. + +### Notes + +- The SSH user's identity is known at connection time (unlike telnet, where login + happens after connecting), so the session lifecycle needs a minor branch: skip + the welcome.ans → login prompt sequence and jump to the authenticated path. +- Public key auth is a natural follow-on but not required for the initial + implementation. Password auth matches the existing BBS credential model. +- Vendor `golang.org/x/crypto/ssh` into the vendor directory. + + +## Step 19 — Door Games + +**Priority: 2 — Highest user-facing impact** + +Door games are external programs launched from within the BBS, with the user's +terminal piped through. This was the killer feature of multi-node BBSes in the +late '80s and '90s. Supporting even a basic door interface opens up a large +ecosystem of existing door game binaries. + +### Sub-steps + +- **19a — Door configuration and menu integration.** + Add a `[doors]` config section defining available doors: name, command path, + arguments, working directory, and required SecStatus. Add a `[D]oors` command + to the main menu (relocate the existing `[D] Download` to another key or make + it a sub-option). List available doors and let the user select one. + +- **19b — Dropout file generation.** + Generate a DOOR32.SYS dropout file before launching the door. DOOR32.SYS is + the most widely supported modern format and includes: comm type (2=telnet), + socket handle, baud rate (0 for telnet), BBS name, user info, time remaining, + and node number. Write the file to a per-node temp directory. + +- **19c — Process execution and I/O bridging.** + Launch the door process with `os/exec`, connecting its stdin/stdout to the + user's telnet (or SSH) connection. Set environment variables that doors + commonly expect: `DOORNODE`, `DROPFILE`, etc. The door process inherits the + terminal. On exit, return the user to the main menu. Handle process timeouts + tied to the user's remaining session time. + +- **19d — Cleanup and logging.** + Remove temp dropout files after the door exits. Log door usage (door name, + user, duration). Handle abnormal door exits gracefully (process crash, timeout, + signal). Ensure the terminal state is sane after the door returns (re-send + ANSI reset, re-negotiate telnet options if needed). + +### Notes + +- Many classic doors expect a DOS-like environment. Running them may require + DOSBox or similar on Linux. URIT's role is just the I/O bridge and dropout + file — the door binary's compatibility is the sysop's responsibility. +- Native Linux doors (written for systems like Mystic, Synchronet) will work + more naturally. Some modern door games are written specifically for telnet BBSes. +- Consider a `door_log` table for tracking door usage statistics. + + +## Step 20 — Message Threading + +**Priority: 3 — Model is ready, UI work needed** + +The `ReplyTo` field exists on the Message model but is not surfaced in the UI. +Wiring it up gives boards a threaded conversation feel rather than a flat +chronological list. + +### Sub-steps + +- **20a — Reply command in message reader.** + When reading a message, add an `[R]eply` option that pre-fills the subject + with "Re: {original subject}" and sets ReplyTo to the parent message ID. + The compose flow is otherwise identical to posting a new message. + +- **20b — Thread-aware message listing.** + Modify the board message list to indicate threading. Two approaches to + consider: indented tree view (classic Usenet style) or flat list with + "Re: ..." subjects and a "view thread" command that filters to a single + conversation. The flat approach is simpler and more BBS-authentic. + +- **20c — Store support for thread queries.** + Add `ListMessageThread(boardID, rootMessageID)` to the store interface. This + returns all messages in a thread (the root plus all descendants) in + chronological order. Use a recursive CTE in SQLite for efficient traversal. + +### Notes + +- Keep the default board view as a flat chronological list — threading is + additive, not a replacement. Users who don't care about threads should see + no change in behavior. +- The HTTP side doesn't currently display messages, but if it ever does, + threading support in the store will carry over naturally. + + +## Step 21 — Email Notifications + +**Priority: 4 — Bridges BBS and modern communication** + +Lightweight email notifications that pull users back to the BBS when something +happens that's relevant to them. + +### Sub-steps + +- **21a — SMTP configuration.** + Add an `[email]` config section: enabled flag, SMTP host/port, from address, + auth credentials (optional), TLS mode (none/starttls/tls). Add an `Email` + field to the User model for the notification address (set during registration + or via account settings). + +- **21b — Notification triggers.** + Send emails on: new private mail received, reply to a message the user posted + (requires step 20), and sysop broadcast (optional). Each trigger should be + individually toggleable per user via account preferences. + +- **21c — Mail queue and sender.** + Implement a background goroutine that processes a notification queue. Outbound + emails are queued (in-memory channel or a database table for persistence) and + sent asynchronously so they never block the BBS session. Rate limit to avoid + overwhelming the SMTP relay. Include an unsubscribe link or instructions in + every email. + +- **21d — User preferences.** + Add a notification preferences submenu to the `[A]ccount` command. Let users + set their email address, enable/disable each notification type, and set a + digest preference (immediate vs. daily summary). Store preferences in a new + `user_preferences` table or as additional fields on the user record. + +### Notes + +- Keep emails plain text with minimal formatting — matches the BBS aesthetic + and avoids HTML email complexity. +- Consider a daily digest mode that batches notifications into a single email + to avoid spamming active boards. +- The email address doubles as an account recovery mechanism in the future. + + +## Step 22 — Automated Backup + +**Priority: 5 — Operational reliability** + +A `urit backup` subcommand that snapshots all BBS state into a single archive. +Designed to be run from cron on unattended systems. + +### Sub-steps + +- **22a — Database snapshot.** + Use SQLite's online backup API (`sqlite3_backup_init`) via the CGo binding to + create a consistent snapshot of the database while the server is running. This + is safe for concurrent reads/writes — no need to stop the server. Write the + snapshot to a temp file, then move it into the backup directory. + +- **22b — File collection.** + Copy the screens directory and all library file directories (paths read from + the library records in the database) into the backup staging area. Skip files + that haven't changed since the last backup if a `--incremental` flag is set + (compare mtimes). + +- **22c — Archive and rotation.** + Tar/gzip the staged files into a timestamped archive: + `urit-backup-YYYYMMDD-HHMMSS.tar.gz`. Support a `--keep N` flag that + automatically deletes backups older than the N most recent. Default output + directory configurable via `--output` flag or a `[backup]` config section. + +- **22d — Cron integration.** + Document cron usage in the sysop guide. Example: + `0 3 * * * /opt/urit/urit backup -config /opt/urit/config.toml --keep 7` + The command should exit cleanly with appropriate exit codes (0=success, + 1=partial failure, 2=fatal error) and log to stdout for cron mail capture. + +### Notes + +- The backup command connects to the database read-only — it does not need the + server to be running, but it works safely if it is. +- Consider adding a `--verify` flag that restores the backup to a temp directory + and runs a schema integrity check. +- A `urit restore` subcommand is a natural follow-on but lower priority. + + +## Steps 23+ — Future Considerations + +The following features are candidates for future versions. Order and scope are +flexible. + +### Full-text search (Step 23) + +Add keyword search across message boards and mail using SQLite's FTS5 extension. +A `[/]` command at the board level searches subject and body text. Results +displayed as a filtered message list. FTS5 is already compiled into the CGo +binding — the work is creating the virtual table, keeping it in sync with +inserts, and building the search UI. + +### File descriptions on telnet (Step 24) + +The telnet library browser shows filenames but lacks the rich file listing that +classic BBSes were known for. Add a proper file list display with descriptions, +sizes, dates, download counts, and uploader names — the traditional columnar +BBS file list format. The data is all in the store already; this is purely a +display task. + +### ANSI auto-detection (Step 25) + +Detect terminal capabilities during telnet negotiation or ask during login. +Provide plain-text fallback screens for minimal clients. Add a user preference +to override detection. Low priority since virtually all modern telnet clients +support ANSI, but it's a polish item for accessibility. + +### Rate limiting and connection throttling (Step 26) + +Add per-IP connection limiting (max N concurrent connections per IP) and a brief +delay after failed login attempts. The max node count provides a global cap, but +per-IP limits would prevent a single source from consuming all nodes. Important +for public-facing systems. + +### Web admin console expansion (Step 27) + +Extend the existing `/admin` dashboard with full CRUD for users, boards, +libraries, and bulletins — a complete browser-based replacement for the telnet +sysop menu. The middleware, template system, and store methods all exist; this +is primarily HTML form work. See the step 16 discussion for scope analysis. + +--- + +## Version Summary + +| Step | Feature | Priority | Effort | +|------|------------------------|----------|----------| +| 18 | SSH support | 1 | Medium | +| 19 | Door games | 2 | Medium | +| 20 | Message threading | 3 | Low | +| 21 | Email notifications | 4 | Medium | +| 22 | Automated backup | 5 | Low–Med | +| 23 | Full-text search | — | Low | +| 24 | Telnet file listings | — | Low | +| 25 | ANSI auto-detection | — | Low | +| 26 | Rate limiting | — | Low | +| 27 | Web admin expansion | — | High | diff --git a/docs/URIT-BBS-Sysop-Guide.docx b/docs/URIT-BBS-Sysop-Guide.docx new file mode 100644 index 0000000..54de5bb Binary files /dev/null and b/docs/URIT-BBS-Sysop-Guide.docx differ diff --git a/docs/sysop-guide.js b/docs/sysop-guide.js new file mode 100644 index 0000000..6a66b7f --- /dev/null +++ b/docs/sysop-guide.js @@ -0,0 +1,894 @@ +const fs = require("fs"); +const { + Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, + Header, Footer, AlignmentType, LevelFormat, HeadingLevel, + BorderStyle, WidthType, ShadingType, PageNumber, PageBreak, + TabStopType, TabStopPosition +} = require("docx"); + +// --- Color palette --- +const CLR = { + title: "1A5276", + heading: "1A5276", + accent: "2E75B6", + dimText: "555555", + tblHead: "D6EAF8", + tblAlt: "F2F8FC", + border: "B0C4DE", + black: "000000", + white: "FFFFFF", +}; + +const border = { style: BorderStyle.SINGLE, size: 1, color: CLR.border }; +const borders = { top: border, bottom: border, left: border, right: border }; +const cellMargins = { top: 60, bottom: 60, left: 100, right: 100 }; +const noBorders = { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, +}; + +// --- Helpers --- +function h1(text) { + return new Paragraph({ + heading: HeadingLevel.HEADING_1, + children: [new TextRun(text)], + spacing: { before: 360, after: 200 }, + }); +} + +function h2(text) { + return new Paragraph({ + heading: HeadingLevel.HEADING_2, + children: [new TextRun(text)], + spacing: { before: 280, after: 160 }, + }); +} + +function h3(text) { + return new Paragraph({ + heading: HeadingLevel.HEADING_3, + children: [new TextRun(text)], + spacing: { before: 200, after: 120 }, + }); +} + +function para(text, opts = {}) { + const runs = []; + if (typeof text === "string") { + runs.push(new TextRun({ text, ...opts })); + } else { + // Array of TextRun configs + for (const t of text) { + runs.push(new TextRun(t)); + } + } + return new Paragraph({ + children: runs, + spacing: { after: 140 }, + }); +} + +function bold(text) { + return { text, bold: true }; +} + +function code(text) { + return { text, font: "Courier New", size: 20, color: "333333" }; +} + +function codeBlock(lines) { + return lines.map(line => + new Paragraph({ + children: [new TextRun({ text: line || " ", font: "Courier New", size: 19, color: "333333" })], + spacing: { after: 20 }, + indent: { left: 400 }, + shading: { type: ShadingType.CLEAR, fill: "F5F5F5" }, + }) + ); +} + +function bullet(text, opts = {}) { + const runs = []; + if (typeof text === "string") { + runs.push(new TextRun(text)); + } else { + for (const t of text) runs.push(new TextRun(t)); + } + return new Paragraph({ + numbering: { reference: "bullets", level: opts.level || 0 }, + children: runs, + spacing: { after: 80 }, + }); +} + +function makeRow(cells, opts = {}) { + return new TableRow({ + children: cells.map((text, i) => { + const widths = opts.widths || [4680, 4680]; + return new TableCell({ + borders, + width: { size: widths[i], type: WidthType.DXA }, + margins: cellMargins, + shading: opts.shading ? { fill: opts.shading, type: ShadingType.CLEAR } : undefined, + children: [ + new Paragraph({ + children: [new TextRun({ + text: String(text), + bold: opts.header || false, + font: "Arial", + size: opts.header ? 21 : 20, + color: opts.header ? CLR.heading : CLR.black, + })], + }), + ], + }); + }), + }); +} + +function makeTable(headers, rows, widths) { + const totalWidth = widths.reduce((a, b) => a + b, 0); + return new Table({ + width: { size: totalWidth, type: WidthType.DXA }, + columnWidths: widths, + rows: [ + makeRow(headers, { widths, header: true, shading: CLR.tblHead }), + ...rows.map((row, i) => + makeRow(row, { widths, shading: i % 2 === 1 ? CLR.tblAlt : undefined }) + ), + ], + }); +} + +function spacer() { + return new Paragraph({ spacing: { after: 80 }, children: [] }); +} + +// --- Document content --- +const children = []; + +// ==================== TITLE PAGE ==================== +children.push(new Paragraph({ spacing: { before: 3000 }, children: [] })); +children.push(new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: "URIT BBS", size: 72, bold: true, color: CLR.title, font: "Arial" })], +})); +children.push(new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { after: 400 }, + children: [new TextRun({ text: "Sysop Guide", size: 48, color: CLR.accent, font: "Arial" })], +})); +children.push(new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { after: 200 }, + children: [new TextRun({ text: "Version 0.2.0", size: 24, color: CLR.dimText, font: "Arial" })], +})); +children.push(new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ + text: "A modern reimplementation of T.A.G.-BBS (1986) in Go", + size: 22, color: CLR.dimText, font: "Arial", italics: true, + })], +})); +children.push(new Paragraph({ children: [new PageBreak()] })); + +// ==================== 1. INTRODUCTION ==================== +children.push(h1("Introduction")); +children.push(para( + "URIT is a multi-node BBS written in Go, inspired by T.A.G.-BBS (The Amiga Gazette BBS, 1986). " + + "It preserves the classic BBS experience\u2014ANSI screens, message boards, private mail, file libraries, " + + "bulletins\u2014while replacing the Amiga serial port with telnet, flat files with SQLite, and ZMODEM " + + "with HTTP file transfers. Features that the single-node original never had, such as multi-node " + + "support and inter-node chat, are included." +)); +children.push(para( + "This guide covers everything a sysop needs to install, configure, and manage a URIT BBS. It assumes " + + "familiarity with BBS concepts (nodes, security levels, message boards, file areas) but not with " + + "URIT or TAG specifically." +)); + +// ==================== 2. BUILDING AND INSTALLING ==================== +children.push(h1("Building and Installing")); +children.push(para("URIT is a single Go binary with no runtime dependencies beyond SQLite (bundled via CGo). You need:")); +children.push(bullet([bold("Go 1.22+"), { text: " \u2014 " }, code("go version"), { text: " to verify" }])); +children.push(bullet([bold("GCC"), { text: " \u2014 required by the SQLite CGo binding (gcc or musl-gcc)" }])); +children.push(spacer()); +children.push(para("Clone and build:")); +children.push(...codeBlock([ + "git clone https://github.com/urit/urit.git", + "cd urit", + "go build -o urit ./cmd/urit/", +])); +children.push(para( + "This produces a single binary. Dependencies are vendored, so no network access is needed during the build. " + + "Copy the binary wherever you like; all runtime paths are configurable." +)); + +// ==================== 3. QUICK START ==================== +children.push(h1("Quick Start")); +children.push(para("Initialize a new BBS instance and start it:")); +children.push(...codeBlock([ + "./urit init", + "./urit", +])); +children.push(para([ + { text: "The " }, code("init"), { text: " command walks you through naming the BBS and creating the sysop account. " + + "It creates a config.toml, a data directory with the SQLite database, seed content (boards, a bulletin, " + + "a file library), and a screens directory with starter ANSI files." } +])); +children.push(para([ + { text: "Once running, connect via telnet on port 2323 (" }, code("telnet localhost 2323"), + { text: ") and visit " }, code("http://localhost:8080"), + { text: " in a browser to see the landing page." } +])); + +// ==================== 4. COMMAND-LINE USAGE ==================== +children.push(h1("Command-Line Usage")); +children.push(para("URIT has two modes: server and initialization.")); +children.push(spacer()); +children.push(makeTable( + ["Command", "Description"], + [ + ["urit", "Start the BBS server"], + ["urit init", "Initialize a new BBS instance"], + ["urit version", "Print version and exit"], + ["urit help", "Show help"], + ], + [3000, 6360], +)); +children.push(spacer()); +children.push(para([ + { text: "Both " }, code("urit"), { text: " and " }, code("urit init"), + { text: " accept " }, code("-config "), + { text: " to specify a configuration file (default: config.toml in the current directory). " + + "The init command also accepts " }, code("-force"), { text: " to overwrite an existing database." }, +])); + +// ==================== 5. CONFIGURATION ==================== +children.push(h1("Configuration")); +children.push(para( + "URIT uses a single TOML file for all configuration. A default config.toml is created during " + + "initialization. Below is a reference of every section." +)); + +// --- [system] --- +children.push(h2("[system]")); +children.push(makeTable( + ["Key", "Default", "Description"], + [ + ["name", '"URIT BBS"', "BBS name shown on banners and the HTTP landing page"], + ["sysop", '"Sysop"', "Sysop display name"], + ["location", '"./data/"', "Base data directory"], + ["screens", '"./screens/"', "Path to ANSI screen files"], + ], + [2000, 2200, 5160], +)); + +// --- [telnet] --- +children.push(h2("[telnet]")); +children.push(makeTable( + ["Key", "Default", "Description"], + [ + ["enabled", "true", "Enable or disable the telnet listener"], + ["address", '":2323"', "Listen address and port"], + ], + [2000, 2200, 5160], +)); + +// --- [http] --- +children.push(h2("[http]")); +children.push(makeTable( + ["Key", "Default", "Description"], + [ + ["enabled", "true", "Enable or disable the HTTP file server"], + ["address", '":8080"', "Listen address and port"], + ], + [2000, 2200, 5160], +)); +children.push(para( + "The HTTP server provides the system landing page, file library browsing, file uploads and downloads, " + + "and a health-check endpoint at /health." +)); + +// --- [storage] --- +children.push(h2("[storage]")); +children.push(makeTable( + ["Key", "Default", "Description"], + [ + ["driver", '"sqlite"', "Storage backend (only sqlite is supported)"], + ["sqlite_path", '"./data/urit.db"', "Path to the SQLite database file"], + ], + [2000, 2800, 4560], +)); + +// --- [users] --- +children.push(h2("[users]")); +children.push(makeTable( + ["Key", "Default", "Description"], + [ + ["max_accounts", "500", "Maximum registered accounts"], + ["guest_time_limit", "1800", "Guest session time limit in seconds (30 min)"], + ["new_time_limit", "3600", "New user session time limit (60 min)"], + ["valid_time_limit", "7200", "Validated user time limit (120 min)"], + ], + [2600, 1600, 5160], +)); +children.push(spacer()); +children.push(para( + "Time limits are enforced per calendar day. If a user reconnects within 12 hours, their remaining " + + "time carries over from the previous session. After 12 hours, the timer resets. The sysop account " + + "(SecStatus 255) always gets a full reset on every login." +)); + +// --- [users.*_security] --- +children.push(h2("[users.guest_security] / [users.new_security] / [users.valid_security]")); +children.push(makeTable( + ["Key", "Description"], + [ + ["status", "SecStatus tier assigned to users in this class"], + ["board", "SecBoard level assigned to users in this class"], + ["library", "SecLibrary level assigned to users in this class"], + ["bulletin", "SecBulletin level assigned to users in this class"], + ], + [2000, 7360], +)); +children.push(spacer()); +children.push(para( + "These define the default security levels for each user class. When someone registers, they receive " + + "the new_security values. When the sysop validates them, they receive the valid_security values. " + + "See the Security Model section for details on what the levels mean." +)); + +// --- [logging] --- +children.push(h2("[logging]")); +children.push(makeTable( + ["Key", "Default", "Description"], + [ + ["level", '"info"', "Log verbosity"], + ["file", '""', "Log file path (empty means stdout)"], + ], + [2000, 2200, 5160], +)); + +// ==================== 6. SECURITY MODEL ==================== +children.push(h1("Security Model")); +children.push(para( + "URIT uses a four-axis security system inherited from the original TAG-BBS. Every user has four " + + "independent security levels that control what they can access." +)); + +children.push(h2("SecStatus \u2014 Account Tier")); +children.push(para( + "This is the overall account classification. It determines which main menu commands are available " + + "and gates access to the sysop menu." +)); +children.push(spacer()); +children.push(makeTable( + ["Range", "Label", "Description"], + [ + ["0", "Guest", "Unauthenticated browser; limited to public commands"], + ["1", "New", "Registered but awaiting sysop validation"], + ["2\u201399", "Valid", "Normal validated user"], + ["100\u2013149", "BoardOp", "Can moderate message boards"], + ["150\u2013254", "LibOp", "Can manage file libraries"], + ["255", "Sysop", "Full administrative access"], + ], + [1600, 1400, 6360], +)); + +children.push(h2("SecBoard, SecLibrary, SecBulletin")); +children.push(para( + "These are numeric levels (typically 0\u201310) checked against the Low/High access range on each board, " + + "library, or bulletin. For example, a board with ReadLow=2 and ReadHigh=10 is visible to any user " + + "whose SecBoard is between 2 and 10 inclusive." +)); +children.push(para( + "This lets you create tiered content: public boards at level 0, member boards at level 2, staff " + + "boards at level 5, and so on. The same pattern applies to libraries (with separate ranges for " + + "upload and download access) and bulletins." +)); +children.push(para( + "The sysop can edit all four security levels per user from the sysop menu." +)); + +// ==================== 7. SCREEN FILES ==================== +children.push(h1("Screen Files")); +children.push(para( + "Screen files are ANSI art text files displayed at various points during a session. They live in " + + "the directory specified by system.screens (default: ./screens/). Replace them with your own " + + "ANSI art to customize the look and feel of your BBS." +)); +children.push(spacer()); +children.push(makeTable( + ["File", "When Displayed"], + [ + ["welcome.ans", "Before the login prompt (pre-auth banner)"], + ["login.ans", "Alongside the login prompt"], + ["guest.ans", "After guest login"], + ["newuser.ans", "After first-time registration"], + ["logon.ans", "After successful login for returning users"], + ["notime.ans", "When a user has no remaining time (then disconnected)"], + ["mainmenu.ans", "Above the main menu (blank = show generated command list)"], + ["help.ans", "For the [?] help command (blank = generated list)"], + ["join.ans", "Before the account creation prompts"], + ["joined.ans", "After successful account creation"], + ["goodbye.ans", "At logoff"], + ], + [2400, 6960], +)); +children.push(spacer()); +children.push(para( + "Bulletin display files are stored wherever the bulletin\u2019s FilePath points. These are managed " + + "through the sysop bulletin menu." +)); +children.push(para( + "Screen files use standard ANSI escape codes. Classic BBS ANSI editors such as PabloDraw, Moebius, " + + "and SyncDraw produce compatible output. Keep line widths to 80 columns for the best terminal experience." +)); + +// ==================== 8. THE SYSOP MENU ==================== +children.push(h1("The Sysop Menu")); +children.push(para([ + { text: "Log in as the sysop account (created during " }, code("urit init"), + { text: ") and press " }, bold("E"), { text: " at the main menu to enter the sysop management area." }, +])); +children.push(spacer()); +children.push(makeTable( + ["Key", "Command", "Description"], + [ + ["L", "List all users", "Paginated user listing with status and last login"], + ["N", "New/unvalidated", "Shows only users with SecStatus=1 awaiting validation"], + ["V", "View/edit user", "Full account editor (security, stats, password, active flag)"], + ["B", "Board management", "Create, edit, delete message boards"], + ["U", "Bulletin management", "Create, edit, delete bulletins and their display files"], + ["F", "File library mgmt", "Create, edit, delete file libraries with access ranges"], + ["C", "Call log", "Last 30 connection events with timestamps, IPs, and details"], + ["X", "Force disconnect", "Kick a node by number (sends courtesy message first)"], + ["Q", "Return", "Back to the main menu"], + ], + [800, 2800, 5760], +)); + +children.push(h2("Validating New Users")); +children.push(para( + "When someone registers, they receive SecStatus=1 (New). They can use the BBS but with limited " + + "access defined by the new_security configuration. To validate a user, navigate to the sysop menu " + + "and press V to view/edit their account, then press 2 to validate. This promotes the user to " + + "the valid_security levels defined in your config. You can also manually adjust their security " + + "levels with keys F through I." +)); + +children.push(h2("User Account Editor")); +children.push(para( + "From the sysop menu, press V and enter a user ID. The editor displays all account fields and " + + "accepts single-key commands." +)); +children.push(spacer()); +children.push(makeTable( + ["Key", "Action"], + [ + ["1", "Save changes"], + ["ESC", "Cancel (discard changes)"], + ["2", "Validate user (set to valid security levels)"], + ["3", "Toggle active/deactivated"], + ["D", "Permanently delete user"], + ["A", "Change username"], + ["B", "Reset password"], + ["C", "Edit comments"], + ["F", "Set SecStatus"], + ["G", "Set SecBoard"], + ["H", "Set SecLibrary"], + ["I", "Set SecBulletin"], + ["J\u2013N", "Edit stats (messages posted, mail sent/received, uploads, downloads)"], + ["Q", "Set time limit"], + ["R", "Set time used"], + ], + [1200, 8160], +)); + +children.push(h2("Board Management")); +children.push(para( + "The board management submenu (sysop menu \u2192 B) lets you list, create, edit, and delete " + + "message boards. Each board has a name and four access boundaries: ReadLow, ReadHigh, WriteLow, " + + "and WriteHigh, all checked against the user\u2019s SecBoard level." +)); +children.push(para( + "A board with ReadLow=0 and ReadHigh=255 is visible to everyone. Setting WriteLow=2 means guests " + + "and new users can read but not post. MaxPosts sets the board\u2019s capacity; when reached, the " + + "oldest messages are purged as new ones arrive." +)); + +children.push(h2("Library Management")); +children.push(para( + "The library management submenu (sysop menu \u2192 F) manages file libraries. Libraries have " + + "separate upload and download access ranges, both checked against the user\u2019s SecLibrary level." +)); +children.push(para([ + { text: "A library with DownloadLow=0 is publicly browsable via the HTTP server without login. " + + "Set DownloadLow higher to restrict access to authenticated users. UploadLow and UploadHigh " + + "control who can upload files. MaxFiles sets the capacity limit. " }, + bold("FilePath"), + { text: " is the directory on disk where uploaded files are stored; the server creates it " + + "automatically on the first upload." }, +])); + +children.push(h2("Bulletin Management")); +children.push(para( + "Bulletins (sysop menu \u2192 U) are ANSI text files displayed to users from the bulletin menu. " + + "Each bulletin has a name, a file path pointing to the display file on disk, and a " + + "ReadLow/ReadHigh range checked against SecBulletin." +)); + +// ==================== 9. HTTP FILE SERVER ==================== +children.push(h1("HTTP File Server")); +children.push(para( + "The HTTP server (port 8080 by default) is the modern replacement for ZMODEM and other file " + + "transfer protocols. It provides library browsing, file downloads, and file uploads through " + + "a browser interface styled to match the BBS\u2019s terminal aesthetic." +)); + +children.push(h2("Routes")); +children.push(makeTable( + ["Path", "Purpose"], + [ + ["/", "Landing page with system name, stats, and telnet address"], + ["/health", "JSON health check for monitoring"], + ["/login", "Browser-based login (BBS username/password)"], + ["/logout", "Clear session"], + ["/libraries", "List of accessible file libraries"], + ["/libraries/{id}", "File listing for a specific library"], + ["/libraries/{id}/files/{n}", "File download"], + ["/libraries/{id}/upload", "File upload form"], + ], + [3800, 5560], +)); + +children.push(h2("Authentication")); +children.push(para("The HTTP server supports two authentication methods.")); +children.push(para([ + bold("Browser login"), + { text: " \u2014 Visit /login and enter your BBS username and password. This creates a cookie-based " + + "session lasting 24 hours. The same bcrypt password check as the telnet side is used." }, +])); +children.push(para([ + bold("Telnet tokens"), + { text: " \u2014 From the BBS main menu, press D to generate a one-time URL. Open it in your browser " + + "to get authenticated with your BBS security levels. Tokens expire after one hour. This is " + + "convenient for telnet users who want to download files without re-entering their password." }, +])); +children.push(para( + "Anonymous visitors see only public libraries (DownloadLow=0). Authenticated users see all " + + "libraries matching their SecLibrary level." +)); + +children.push(h2("File Uploads")); +children.push(para( + "Authenticated users can upload to libraries where their SecLibrary falls within the library\u2019s " + + "UploadLow\u2013UploadHigh range. The upload form is accessible from the file listing page. " + + "Files are limited to 50 MB. Duplicate filenames within the same library are rejected. The " + + "library\u2019s directory on disk is created automatically if it does not exist." +)); + +// ==================== 10. INTER-NODE CHAT ==================== +children.push(h1("Inter-Node Chat")); +children.push(para([ + { text: "URIT supports real-time communication between connected users. Press " }, + bold("C"), + { text: " at the main menu to enter the chat system. Three modes are available." }, +])); +children.push(para([ + bold("Page"), + { text: " \u2014 Send a one-line message to another node. The message appears on their terminal " + + "immediately, regardless of what they are doing. No chat mode is required on either end." }, +])); +children.push(para([ + bold("Chat"), + { text: " \u2014 Enter real-time line-by-line messaging with another node. When you start a chat, " + + "the target user receives a notification. If they also press C and select your node, the " + + "channels link and messages flow bidirectionally. Type /quit to exit. If either user " + + "disconnects, the partner is notified." }, +])); +children.push(para([ + bold("Broadcast"), + { text: " (sysop only) \u2014 Send a message to every connected node at once. Useful for " + + "maintenance announcements or server shutdown warnings." }, +])); + +// ==================== 11. MAIN MENU COMMANDS ==================== +children.push(h1("Main Menu Commands")); +children.push(para( + "The following commands are available from the main menu. Guest users see only commands marked " + + "as guest-accessible. Registered users see all non-sysop commands." +)); +children.push(spacer()); +children.push(makeTable( + ["Key", "Name", "Description"], + [ + ["I", "Info", "System information"], + ["S", "Stats", "System statistics (calls, messages, time)"], + ["T", "Time", "Session time remaining"], + ["W", "Who", "Who\u2019s online (node list)"], + ["A", "Account", "View your account details"], + ["P", "Mail", "Private mail system"], + ["M", "Messages", "Message boards"], + ["B", "Bulletins", "Bulletin listings"], + ["L", "Library", "File library browser (telnet side)"], + ["D", "Download", "Generate a web download token URL"], + ["C", "Chat", "Page/chat with other users"], + ["U", "Users", "User listings"], + ["F", "Feedback", "Send a note to the sysop (delivered as mail)"], + ["J", "Join", "Create a permanent account (guest only)"], + ["?", "Help", "Command list"], + ["G", "Goodbye", "Log off"], + ["E", "Sysop", "Sysop management menu (sysop only)"], + ], + [800, 1600, 6960], +)); + +// ==================== 12. STATISTICS AND CALL LOG ==================== +children.push(h1("Statistics and Call Log")); +children.push(para("URIT tracks system activity in two ways.")); +children.push(para([ + bold("Stats counters"), + { text: " are atomic key-value counters stored in the database. The system tracks: total_calls, " + + "guest_calls, new_calls, valid_calls, new_accounts, messages_posted, mail_sent, total_time_secs, " + + "files_downloaded, and files_uploaded. Users can view a summary from the main menu with S." }, +])); +children.push(para([ + bold("The call log"), + { text: " records timestamped events (login, logoff, kicked) with the user name, node number, " + + "remote IP, and detail text. The sysop call log (sysop menu \u2192 C) shows the last 30 events " + + "with full detail. The user-facing stats screen shows the last 10 login/logoff events without " + + "IP addresses." }, +])); + +// ==================== 13. SESSION LIFECYCLE ==================== +children.push(h1("Session Lifecycle")); +children.push(para( + "Understanding the session flow helps when troubleshooting or customizing screens. The following " + + "is the sequence from connection to logoff." +)); +children.push(spacer()); +children.push(makeTable( + ["Step", "Action", "Screen File"], + [ + ["1", "Telnet connection; node allocated", "welcome.ans"], + ["2", "Login prompt; username/password checked (3 attempts, then guest)", "login.ans"], + ["3a", "New user: registration flow", "newuser.ans"], + ["3b", "Guest login", "guest.ans"], + ["3c", "Returning user login", "logon.ans"], + ["4", "Time check: if >12 hrs since last login, reset; otherwise carry over", "(none)"], + ["4x", "No time remaining", "notime.ans"], + ["5", "Last caller display, unread mail check, validation notice", "(none)"], + ["6", "Main menu loop", "mainmenu.ans"], + ["7", "Logoff; time/stats saved; node freed", "goodbye.ans"], + ], + [900, 5660, 2800], +)); +children.push(spacer()); +children.push(para( + "The logoff handler runs in a deferred function, so stats and time are saved even if the " + + "connection drops unexpectedly." +)); + +// ==================== 14. DATABASE ==================== +children.push(h1("Database")); +children.push(para( + "All persistent state lives in a single SQLite database (default: ./data/urit.db). The schema " + + "is created automatically on first run." +)); +children.push(spacer()); +children.push(makeTable( + ["Table", "Contents"], + [ + ["users", "User accounts (name, password hash, security levels, stats)"], + ["boards", "Message boards (name, topic, access ranges)"], + ["messages", "Board messages (author, subject, body, timestamps)"], + ["mail", "Private mail between users"], + ["libraries", "File libraries (name, path, access ranges, capacity)"], + ["library_files", "File metadata (name, size, uploader, download count)"], + ["bulletins", "Bulletin definitions (name, display file path)"], + ["call_log", "Timestamped connection events"], + ["stats", "Key-value counters"], + ["web_sessions", "HTTP authentication sessions"], + ], + [2200, 7160], +)); +children.push(spacer()); +children.push(para([ + { text: "The database can be backed up by copying the file while the server is stopped. For a " + + "running system, use " }, + code('sqlite3 urit.db ".backup backup.db"'), + { text: " which is safe for concurrent reads." }, +])); + +// ==================== 15. DEPLOYMENT TIPS ==================== +children.push(h1("Deployment Tips")); + +children.push(h2("Reverse Proxy")); +children.push(para( + "Put nginx or Caddy in front of the HTTP server for TLS. The telnet server does not support TLS " + + "natively; consider a stunnel or haproxy wrapper if you need encrypted telnet connections." +)); + +children.push(h2("Systemd")); +children.push(para( + "Create a service unit that runs the binary with your config file. Set Restart=on-failure for " + + "automatic recovery. Use a dedicated system user for isolation. A minimal unit file:" +)); +children.push(...codeBlock([ + "[Unit]", + "Description=URIT BBS", + "After=network.target", + "", + "[Service]", + "Type=simple", + "User=urit", + "WorkingDirectory=/opt/urit", + "ExecStart=/opt/urit/urit -config /opt/urit/config.toml", + "Restart=on-failure", + "", + "[Install]", + "WantedBy=multi-user.target", +])); + +children.push(h2("Backups")); +children.push(para( + "Back up two things: the SQLite database and the screens directory. File libraries are stored on " + + "disk at the paths configured per library, so include those directories as well. " + + "The config.toml should be kept in version control or backed up separately." +)); + +children.push(h2("Monitoring")); +children.push(para([ + { text: "The " }, code("/health"), + { text: " endpoint returns JSON with the system name and current timestamp. " + + "Point your monitoring tool at it for basic availability checks." }, +])); + +// ==================== 16. APPENDIX: CONFIG DEFAULTS ==================== +children.push(h1("Appendix: Default Configuration")); +children.push(para("For reference, here is the complete default config.toml produced by initialization:")); +children.push(...codeBlock([ + '[system]', + 'name = "URIT BBS"', + 'sysop = "Sysop"', + 'location = "./data/"', + 'screens = "./screens/"', + '', + '[telnet]', + 'enabled = true', + 'address = ":2323"', + '', + '[http]', + 'enabled = true', + 'address = ":8080"', + '', + '[storage]', + 'driver = "sqlite"', + 'sqlite_path = "./data/urit.db"', + '', + '[users]', + 'max_accounts = 500', + 'guest_time_limit = 1800', + 'new_time_limit = 3600', + 'valid_time_limit = 7200', + '', + '[users.guest_security]', + 'status = 0', + 'board = 0', + 'library = 0', + 'bulletin = 0', + '', + '[users.new_security]', + 'status = 1', + 'board = 1', + 'library = 1', + 'bulletin = 1', + '', + '[users.valid_security]', + 'status = 2', + 'board = 2', + 'library = 2', + 'bulletin = 2', + '', + '[logging]', + 'level = "info"', + 'file = ""', +])); + +// ==================== BUILD DOCUMENT ==================== +const doc = new Document({ + styles: { + default: { + document: { + run: { font: "Arial", size: 22, color: CLR.black }, + }, + }, + paragraphStyles: [ + { + id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 36, bold: true, font: "Arial", color: CLR.title }, + paragraph: { + spacing: { before: 360, after: 200 }, + outlineLevel: 0, + border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: CLR.accent, space: 4 } }, + }, + }, + { + id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 28, bold: true, font: "Arial", color: CLR.heading }, + paragraph: { spacing: { before: 240, after: 160 }, outlineLevel: 1 }, + }, + { + id: "Heading3", name: "Heading 3", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 24, bold: true, font: "Arial", color: CLR.heading }, + paragraph: { spacing: { before: 200, after: 120 }, outlineLevel: 2 }, + }, + ], + }, + numbering: { + config: [ + { + reference: "bullets", + levels: [ + { + level: 0, format: LevelFormat.BULLET, text: "\u2022", + alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } }, + }, + { + level: 1, format: LevelFormat.BULLET, text: "\u2013", + alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 1440, hanging: 360 } } }, + }, + ], + }, + ], + }, + sections: [ + { + properties: { + page: { + size: { width: 12240, height: 15840 }, + margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, + }, + }, + headers: { + default: new Header({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: CLR.border, space: 4 } }, + children: [ + new TextRun({ text: "URIT BBS Sysop Guide", font: "Arial", size: 18, color: CLR.dimText }), + ], + }), + ], + }), + }, + footers: { + default: new Footer({ + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + border: { top: { style: BorderStyle.SINGLE, size: 1, color: CLR.border, space: 4 } }, + children: [ + new TextRun({ text: "Page ", font: "Arial", size: 18, color: CLR.dimText }), + new TextRun({ children: [PageNumber.CURRENT], font: "Arial", size: 18, color: CLR.dimText }), + ], + }), + ], + }), + }, + children, + }, + ], +}); + +Packer.toBuffer(doc).then(buffer => { + fs.writeFileSync("/mnt/user-data/outputs/URIT-BBS-Sysop-Guide.docx", buffer); + console.log("Done: URIT-BBS-Sysop-Guide.docx"); +}); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..207fe8e --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/urit/urit + +go 1.22.2 + +require github.com/BurntSushi/toml v1.6.0 + +require ( + github.com/mattn/go-sqlite3 v1.14.34 + golang.org/x/crypto v0.31.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0c0b73a --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..e6358a7 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,396 @@ +// Package auth implements authentication for URIT BBS. +// +// This replaces LOGON.C from the original TAG-BBS. The original used +// numeric account numbers (slot positions in User.Data), plaintext +// passwords compared with StringCompare(), and a simple "3 tries then +// guest" flow. URIT uses usernames, bcrypt password hashing, and +// the same 3-tries-then-guest pattern. +// +// The original's Sysop_Account_Sequence() (auto-login for local console) +// is not needed since we don't have a local serial port. Sysop logs in +// like any other user. +package auth + +import ( + "fmt" + "strings" + "time" + "unicode" + + "golang.org/x/crypto/bcrypt" + + "github.com/urit/urit/internal/config" + "github.com/urit/urit/internal/models" + "github.com/urit/urit/internal/session" + "github.com/urit/urit/internal/store" +) + +const ( + bcryptCost = 12 + maxLoginTries = 3 + inputTimeout = 60 * time.Second + maxNameLen = 30 + maxPassLen = 72 // bcrypt's max input length + minPassLen = 4 +) + +// Result describes the outcome of the authentication flow. +type Result struct { + User *models.User + IsGuest bool + IsNew bool +} + +// Login runs the full login sequence on the given session. +// This is the replacement for Logon_Sequence() in LOGON.C. +// +// The flow: +// 1. Display login screen file if it exists +// 2. Prompt for name or "NEW" or "GUEST" +// 3. If existing user: verify password (3 tries, then forced guest) +// 4. If "NEW": run account creation flow +// 5. If "GUEST": create an ephemeral guest session +// 6. If first-ever user: auto-create as sysop +func Login(sess *session.Session, db store.Store, cfg *config.Config) (*Result, error) { + // Display the login screen file if the sysop has installed one + sess.SendFile(cfg.System.Screens + "login.ans") + + // Check if this is a fresh install (no users yet). + // If so, the first person to create an account becomes the sysop. + userCount, err := db.CountUsers() + if err != nil { + return nil, fmt.Errorf("counting users: %w", err) + } + firstRun := userCount == 0 + + if firstRun { + sess.Color(session.AnsiFgBrightYellow) + sess.WriteString("*** First run detected — first account will be Sysop ***\r\n") + sess.Color(session.AnsiReset) + sess.NewLine() + } + + tries := 0 + + for { + if tries >= maxLoginTries { + sess.WriteString("Three tries and you're out.\r\n") + sess.WriteString("Now you get a Guest account.\r\n\r\n") + return guestLogin(sess, cfg), nil + } + + sess.Color(session.AnsiFgCyan) + sess.WriteString("Enter your username (NEW for new account, GUEST for guest): ") + sess.Color(session.AnsiReset) + + name, err := sess.ReadLine("", maxNameLen, inputTimeout) + if err != nil { + return nil, err + } + + name = strings.TrimSpace(name) + if name == "" { + tries++ + continue + } + + upper := strings.ToUpper(name) + + // Guest access + if upper == "GUEST" { + return guestLogin(sess, cfg), nil + } + + // New account + if upper == "NEW" { + if firstRun { + return newAccount(sess, db, cfg, true) + } + return newAccount(sess, db, cfg, false) + } + + // Existing user login + user, err := db.GetUserByName(name) + if err != nil { + return nil, fmt.Errorf("looking up user: %w", err) + } + if user == nil { + sess.WriteString("No account with that name.\r\n") + // Offer to create if name looks intentional + yes, err := sess.Confirm("Create a new account? (Y/N) ", inputTimeout) + if err != nil { + return nil, err + } + if yes { + if firstRun { + return newAccountWithName(sess, db, cfg, name, true) + } + return newAccountWithName(sess, db, cfg, name, false) + } + tries++ + continue + } + + // User found — verify password + result, err := passwordLogin(sess, db, cfg, user) + if err != nil { + return nil, err + } + if result != nil { + return result, nil + } + + // Password failed + tries++ + } +} + +// passwordLogin prompts for a password and verifies it against the +// stored bcrypt hash. Returns nil result if password is wrong. +func passwordLogin(sess *session.Session, db store.Store, cfg *config.Config, user *models.User) (*Result, error) { + sess.Color(session.AnsiFgCyan) + sess.WriteString("Password: ") + sess.Color(session.AnsiReset) + + pass, err := sess.ReadLineNoEcho(maxPassLen, inputTimeout) + if err != nil { + return nil, err + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(pass)); err != nil { + sess.WriteString("Invalid password.\r\n\r\n") + return nil, nil // Signal: wrong password, but not a fatal error + } + + // Successful login — update last-on time + now := time.Now() + user.LastOn = &now + user.TimeUsed = 0 // Reset per-session time + if err := db.UpdateUser(user); err != nil { + return nil, fmt.Errorf("updating user: %w", err) + } + + sess.NewLine() + sess.Color(session.AnsiFgGreen) + sess.Printf("Welcome back, %s!\r\n", user.Name) + sess.Color(session.AnsiReset) + + // Set session time limit from user record + sess.TimeLimit = time.Duration(user.TimeLimit) * time.Second + + return &Result{User: user}, nil +} + +// guestLogin creates a transient guest session with no database record. +// This replaces New_Account_Sequence() from the original, which despite +// its name actually created a guest session (not a saved account). +func guestLogin(sess *session.Session, cfg *config.Config) *Result { + guest := &models.User{ + Name: "Guest", + SecStatus: cfg.Users.GuestSecurity.Status, + SecBoard: cfg.Users.GuestSecurity.Board, + SecLibrary: cfg.Users.GuestSecurity.Library, + SecBulletin: cfg.Users.GuestSecurity.Bulletin, + TimeLimit: int64(cfg.Users.GuestTimeLimit), + Active: true, + } + + sess.TimeLimit = time.Duration(cfg.Users.GuestTimeLimit) * time.Second + + // Display guest login screen if available + sess.SendFile(cfg.System.Screens + "guest.ans") + + sess.Color(session.AnsiFgYellow) + sess.WriteString("Logged in as Guest.\r\n") + sess.Color(session.AnsiReset) + + return &Result{User: guest, IsGuest: true} +} + +// newAccount runs the new account creation flow. +func newAccount(sess *session.Session, db store.Store, cfg *config.Config, isSysop bool) (*Result, error) { + sess.NewLine() + + // Display new user screen if available + sess.SendFile(cfg.System.Screens + "newuser.ans") + + sess.Color(session.AnsiFgCyan) + sess.WriteString("Choose a username: ") + sess.Color(session.AnsiReset) + + name, err := sess.ReadLine("", maxNameLen, inputTimeout) + if err != nil { + return nil, err + } + + return createAccount(sess, db, cfg, strings.TrimSpace(name), isSysop) +} + +// newAccountWithName runs account creation with a pre-chosen name +// (when the user typed a name that didn't exist and said "yes" to +// creating it). +func newAccountWithName(sess *session.Session, db store.Store, cfg *config.Config, name string, isSysop bool) (*Result, error) { + sess.NewLine() + sess.SendFile(cfg.System.Screens + "newuser.ans") + return createAccount(sess, db, cfg, name, isSysop) +} + +// createAccount handles the shared account creation logic. +func createAccount(sess *session.Session, db store.Store, cfg *config.Config, name string, isSysop bool) (*Result, error) { + // Validate name + if err := validateName(name); err != nil { + sess.Printf("%s\r\n", err) + return guestLogin(sess, cfg), nil + } + + // Check for duplicate + existing, err := db.GetUserByName(name) + if err != nil { + return nil, fmt.Errorf("checking name: %w", err) + } + if existing != nil { + sess.WriteString("That name is already taken.\r\n") + return guestLogin(sess, cfg), nil + } + + // Check account limit + count, err := db.CountUsers() + if err != nil { + return nil, fmt.Errorf("counting users: %w", err) + } + if count >= cfg.Users.MaxAccounts { + sess.WriteString("Sorry, maximum accounts reached.\r\n") + return guestLogin(sess, cfg), nil + } + + // Get password + sess.Color(session.AnsiFgCyan) + sess.WriteString("Choose a password: ") + sess.Color(session.AnsiReset) + + pass, err := sess.ReadLineNoEcho(maxPassLen, inputTimeout) + if err != nil { + return nil, err + } + + if len(pass) < minPassLen { + sess.Printf("Password must be at least %d characters.\r\n", minPassLen) + return guestLogin(sess, cfg), nil + } + + // Confirm password + sess.Color(session.AnsiFgCyan) + sess.WriteString("Confirm password: ") + sess.Color(session.AnsiReset) + + confirm, err := sess.ReadLineNoEcho(maxPassLen, inputTimeout) + if err != nil { + return nil, err + } + + if pass != confirm { + sess.WriteString("Passwords do not match.\r\n") + return guestLogin(sess, cfg), nil + } + + // Hash the password + hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcryptCost) + if err != nil { + return nil, fmt.Errorf("hashing password: %w", err) + } + + // Build the user record + now := time.Now() + user := &models.User{ + Name: name, + PasswordHash: string(hash), + Active: true, + LastOn: &now, + } + + if isSysop { + // First user ever — full sysop privileges + user.SecStatus = 255 + user.SecBoard = 255 + user.SecLibrary = 255 + user.SecBulletin = 255 + user.TimeLimit = 86400 // 24 hours — effectively unlimited + } else { + // Normal new user — gets "new" security tier + user.SecStatus = cfg.Users.NewSecurity.Status + user.SecBoard = cfg.Users.NewSecurity.Board + user.SecLibrary = cfg.Users.NewSecurity.Library + user.SecBulletin = cfg.Users.NewSecurity.Bulletin + user.TimeLimit = int64(cfg.Users.NewTimeLimit) + } + + if err := db.CreateUser(user); err != nil { + // Could be a race condition on duplicate name + sess.WriteString("Error creating account. Please try again.\r\n") + return guestLogin(sess, cfg), nil + } + + sess.TimeLimit = time.Duration(user.TimeLimit) * time.Second + + sess.NewLine() + sess.Color(session.AnsiFgGreen, session.AnsiBold) + if isSysop { + sess.Printf("Sysop account created: %s (ID #%d)\r\n", user.Name, user.ID) + } else { + sess.Printf("Account created: %s (ID #%d)\r\n", user.Name, user.ID) + sess.Color(session.AnsiFgYellow) + sess.WriteString("Your account is NEW and must be validated by the Sysop\r\n") + sess.WriteString("for full access.\r\n") + } + sess.Color(session.AnsiReset) + sess.NewLine() + + return &Result{User: user, IsNew: !isSysop}, nil +} + +// validateName checks that a username meets requirements. +func validateName(name string) error { + if len(name) < 2 { + return fmt.Errorf("name must be at least 2 characters") + } + if len(name) > maxNameLen { + return fmt.Errorf("name must be %d characters or fewer", maxNameLen) + } + + upper := strings.ToUpper(name) + if upper == "GUEST" || upper == "NEW" || upper == "SYSOP" { + return fmt.Errorf("%q is a reserved name", name) + } + + // Must contain at least one letter + hasLetter := false + for _, r := range name { + if unicode.IsLetter(r) { + hasLetter = true + } + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != ' ' && r != '-' && r != '_' && r != '.' { + return fmt.Errorf("name can only contain letters, numbers, spaces, hyphens, underscores, and periods") + } + } + if !hasLetter { + return fmt.Errorf("name must contain at least one letter") + } + + return nil +} + +// HashPassword generates a bcrypt hash for the given password. +// Exported for use by admin tools and tests. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// CheckPassword verifies a password against a bcrypt hash. +// Exported for use by admin tools and tests. +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..649a0cc --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,79 @@ +package auth + +import ( + "testing" +) + +func TestHashAndCheckPassword(t *testing.T) { + hash, err := HashPassword("testpass123") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if hash == "" { + t.Fatal("HashPassword returned empty string") + } + if hash == "testpass123" { + t.Fatal("Hash should not equal plaintext") + } + + // Correct password + if !CheckPassword(hash, "testpass123") { + t.Error("CheckPassword should return true for correct password") + } + + // Wrong password + if CheckPassword(hash, "wrongpassword") { + t.Error("CheckPassword should return false for wrong password") + } + + // Empty password + if CheckPassword(hash, "") { + t.Error("CheckPassword should return false for empty password") + } +} + +func TestValidateName(t *testing.T) { + tests := []struct { + name string + wantErr bool + }{ + {"Alice", false}, + {"Bob Smith", false}, + {"user-1", false}, + {"user_2", false}, + {"test.user", false}, + {"CoolDude99", false}, + + // Too short + {"A", true}, + {"", true}, + + // Reserved + {"GUEST", true}, + {"guest", true}, + {"NEW", true}, + {"new", true}, + {"SYSOP", true}, + {"sysop", true}, + + // No letters + {"123", true}, + {"---", true}, + + // Invalid characters + {"user@name", true}, + {"user!name", true}, + {"user/name", true}, + {"user