From 57d32d0b58dbc70d81836aa5d7cc5800bab3887a Mon Sep 17 00:00:00 2001 From: handfly Date: Sat, 2 May 2026 21:11:50 -0400 Subject: [PATCH] Initial Commit --- .directory | 2 + CREDITS.md | 23 + LICENSE | 24 + README.md | 80 +++ cmd/urit/data/urit.db | Bin 0 -> 102400 bytes cmd/urit/init.go | 462 ++++++++++++ cmd/urit/main.go | 127 ++++ config.toml | 48 ++ docs/ROADMAP-v0.3.0.md | 268 +++++++ docs/URIT-BBS-Sysop-Guide.docx | Bin 0 -> 22567 bytes docs/sysop-guide.js | 894 +++++++++++++++++++++++ go.mod | 10 + go.sum | 6 + internal/auth/auth.go | 396 +++++++++++ internal/auth/auth_test.go | 79 +++ internal/config/config.go | 148 ++++ internal/menu/bulletins.go | 143 ++++ internal/menu/chat.go | 289 ++++++++ internal/menu/library.go | 777 ++++++++++++++++++++ internal/menu/mail.go | 564 +++++++++++++++ internal/menu/menu.go | 863 ++++++++++++++++++++++ internal/menu/messages.go | 902 +++++++++++++++++++++++ internal/menu/nodes.go | 40 ++ internal/menu/sysop.go | 1032 +++++++++++++++++++++++++++ internal/menu/sysop_boards.go | 502 +++++++++++++ internal/menu/sysop_bulletins.go | 399 +++++++++++ internal/menu/sysop_libraries.go | 477 +++++++++++++ internal/models/board.go | 57 ++ internal/models/calllog.go | 20 + internal/models/library.go | 66 ++ internal/models/mail.go | 23 + internal/models/user.go | 88 +++ internal/models/websession.go | 15 + internal/server/admin.go | 464 ++++++++++++ internal/server/chat.go | 131 ++++ internal/server/http.go | 1145 ++++++++++++++++++++++++++++++ internal/server/server.go | 504 +++++++++++++ internal/server/telnet.go | 163 +++++ internal/server/tokens.go | 99 +++ internal/session/input.go | 229 ++++++ internal/session/output.go | 188 +++++ internal/session/session.go | 249 +++++++ internal/session/telnet.go | 163 +++++ internal/store/sqlite.go | 1119 +++++++++++++++++++++++++++++ internal/store/sqlite_test.go | 423 +++++++++++ internal/store/store.go | 101 +++ screens/README | 7 + 47 files changed, 13809 insertions(+) create mode 100644 .directory create mode 100644 CREDITS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/urit/data/urit.db create mode 100644 cmd/urit/init.go create mode 100644 cmd/urit/main.go create mode 100644 config.toml create mode 100644 docs/ROADMAP-v0.3.0.md create mode 100644 docs/URIT-BBS-Sysop-Guide.docx create mode 100644 docs/sysop-guide.js create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/config/config.go create mode 100644 internal/menu/bulletins.go create mode 100644 internal/menu/chat.go create mode 100644 internal/menu/library.go create mode 100644 internal/menu/mail.go create mode 100644 internal/menu/menu.go create mode 100644 internal/menu/messages.go create mode 100644 internal/menu/nodes.go create mode 100644 internal/menu/sysop.go create mode 100644 internal/menu/sysop_boards.go create mode 100644 internal/menu/sysop_bulletins.go create mode 100644 internal/menu/sysop_libraries.go create mode 100644 internal/models/board.go create mode 100644 internal/models/calllog.go create mode 100644 internal/models/library.go create mode 100644 internal/models/mail.go create mode 100644 internal/models/user.go create mode 100644 internal/models/websession.go create mode 100644 internal/server/admin.go create mode 100644 internal/server/chat.go create mode 100644 internal/server/http.go create mode 100644 internal/server/server.go create mode 100644 internal/server/telnet.go create mode 100644 internal/server/tokens.go create mode 100644 internal/session/input.go create mode 100644 internal/session/output.go create mode 100644 internal/session/session.go create mode 100644 internal/session/telnet.go create mode 100644 internal/store/sqlite.go create mode 100644 internal/store/sqlite_test.go create mode 100644 internal/store/store.go create mode 100644 screens/README 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 0000000000000000000000000000000000000000..eb5a218e540de14e4f3b5506ef0580abef8e6210 GIT binary patch literal 102400 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 G3H$|%kg8Dt literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..54de5bbabd591ac4dcc88bf4886d19fb033e3fab GIT binary patch literal 22567 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 literal 0 HcmV?d00001 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