diff --git a/README.md b/README.md index f1a5812..42b425c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ `infinite-noodle` is a small Go service that combines: - a web UI for viewing configured TCP proxies +- a login-protected UI with user and admin account management - a Bitcask-backed data store for persisted proxy definitions - a TCP proxy runner built with Go's standard `net` package @@ -13,12 +14,13 @@ This README is written for working in a GitHub Codespace. Each "noodle" is a TCP forwarding rule with: - a name -- an allowed source (`All` or a specific source IP) +- an allowed source list (`All`, one or more IPs, or CIDR ranges) - a listen port - a destination host - a destination port - an expiration duration - an up/down state +- the user who created it When the app starts, it loads saved noodles from the local database and starts any active proxy routes. It also serves a web UI for creating, pausing, resuming, and deleting them. @@ -27,14 +29,19 @@ When the app starts, it loads saved noodles from the local database and starts a The project is functional enough to: - start the web app +- create a default admin user on a new database +- require login before the proxy management UI is available +- support two user levels: `regular` and `admin` +- let any signed-in user change their own password +- let admin users create users, change roles, change any user's password, and delete user accounts - load stored proxy definitions from `./infinite.db` - create new noodles from the UI - run active TCP and UDP proxies -- restrict a proxy to a specific source IP +- restrict a proxy to comma-separated source IPs and CIDR ranges - update expiration values in the database every second while a proxy is active - close and delete expired noodles automatically - pause a noodle from the UI without decrementing its expiration -- delete existing noodles from the UI +- delete existing noodles from the UI with confirmation Current limitations: @@ -59,6 +66,14 @@ Default runtime settings: Open the app in the browser from the forwarded port for `7878`. +On a brand-new database, the app creates a default UI user: + +- username: `admin` +- password: `admin` +- role: `admin` + +Any signed-in user can access `/users` to manage their own password. Admin users also get full user management on that page. + You can also override the defaults: ```bash @@ -74,10 +89,10 @@ Available flags: ## UI Behavior -The main table includes an add row for creating a proxy with: +The web UI is protected by a login page. After signing in, the main table includes an add row for creating a proxy with: - `Name` -- `Allow From`: accepts `All` or a specific IP address, with the current client IP suggested +- `Allow From`: accepts `All`, comma-separated IP addresses, or CIDR ranges such as `10.0.0.5, 192.168.1.0/24` - `Proto`: choose `TCP` or `UDP` - `Listen Port` - `Destination Port` @@ -89,6 +104,17 @@ The `Status` column is a checkbox: - checked: the proxy is active and the expiration counts down - unchecked: the proxy is closed and the expiration is paused +The table also shows: + +- `Created By`: the authenticated user who created the proxy + +Delete actions in the UI prompt for confirmation before the request is submitted. + +The `/users` page behavior depends on role: + +- `regular`: can change their own password +- `admin`: can create users, change roles, change any password, and delete users + The expiration value is shown as a live countdown in the browser. When it reaches zero, the row is removed from the UI and the noodle is deleted from the database. ## Codespaces Port Notes @@ -143,8 +169,11 @@ GOOS=linux GOARCH=amd64 go build -buildvcs=false -o target/infinite-noodle.net-p - [`cmd/infinite-noodle/main.go`](/data/project/go/src/infinite-noodle/cmd/infinite-noodle/main.go): binary entrypoint and CLI flags - [`internal/app/app.go`](/data/project/go/src/infinite-noodle/internal/app/app.go): app startup, HTTP server, and proxy lifecycle - [`internal/noodle/database.go`](/data/project/go/src/infinite-noodle/internal/noodle/database.go): Bitcask storage layer +- [`internal/web/auth.go`](/data/project/go/src/infinite-noodle/internal/web/auth.go): login, sessions, and role-based access control - [`internal/web/handlers.go`](/data/project/go/src/infinite-noodle/internal/web/handlers.go): HTML handlers - [`internal/assets/templates/index.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/index.html): UI template +- [`internal/assets/templates/login.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/login.html): login page +- [`internal/assets/templates/users.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/users.html): user configuration page ## Verification Note diff --git a/internal/app/app.go b/internal/app/app.go index 4ed36a7..4d06bd5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -28,19 +28,34 @@ func Run(cfg Config) error { db := noodle.NewDatabase(cfg.DataPath) defer db.Close() + auth, err := web.NewAuth(db) + if err != nil { + return err + } + if err := auth.EnsureDefaultUser(); err != nil { + return err + } + if cfg.RunTest { runTestSequence(db) } go systemCheck(db, noodleChannel) go expirationCheck(db, noodleChannel) - go proxify(noodleChannel) + go proxify(db, noodleChannel) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(web.StaticFiles())))) - http.HandleFunc("/", web.HandleMain(db, &noodleChannel)) - http.HandleFunc("/add", web.HandleAdd(db, &noodleChannel)) - http.HandleFunc("/toggle", web.HandleToggle(db, &noodleChannel)) - http.HandleFunc("/delete", web.HandleDelete(db, &noodleChannel)) + http.HandleFunc("/login", auth.HandleLogin()) + http.HandleFunc("/logout", auth.HandleLogout()) + http.HandleFunc("/", auth.RequireAuth(web.HandleMain(db, &noodleChannel, auth))) + http.HandleFunc("/users", auth.RequireAuth(web.HandleUsers(auth, db))) + http.HandleFunc("/users/add", auth.RequireAdmin(web.HandleAddUser(auth, db))) + http.HandleFunc("/users/role", auth.RequireAdmin(web.HandleSetUserRole(auth, db))) + http.HandleFunc("/users/password", auth.RequireAuth(web.HandleChangePassword(auth, db))) + http.HandleFunc("/users/delete", auth.RequireAuth(web.HandleDeleteUser(auth, db))) + http.HandleFunc("/add", auth.RequireAuth(web.HandleAdd(db, &noodleChannel, auth))) + http.HandleFunc("/toggle", auth.RequireAuth(web.HandleToggle(db, &noodleChannel))) + http.HandleFunc("/delete", auth.RequireAuth(web.HandleDelete(db, &noodleChannel))) log.Printf("Server starting on %s", listenAddr) return http.ListenAndServe(listenAddr, nil) @@ -56,7 +71,7 @@ func systemCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) { } } -func proxify(noodleChannel chan noodle.Noodle) { +func proxify(db *noodle.Database, noodleChannel chan noodle.Noodle) { noodleMap := make(map[string]managedProxy) for { item := <-noodleChannel @@ -65,6 +80,9 @@ func proxify(noodleChannel chan noodle.Noodle) { proxy, err := newProxy(item) if err != nil { log.Print(err) + if _, updateErr := db.SetIsUp(item.Id, false); updateErr != nil { + log.Print(updateErr) + } continue } noodleMap[item.Id] = proxy @@ -408,15 +426,73 @@ func (p *udpNoodleProxy) Close() error { return conn.Close() } -func allowSource(allowedIP string, addr net.Addr) bool { - if allowedIP == "" || allowedIP == "All" { +func allowSource(allowedIPs string, addr net.Addr) bool { + if allowedIPs == "" || strings.EqualFold(strings.TrimSpace(allowedIPs), "All") { return true } host, _, err := net.SplitHostPort(addr.String()) if err != nil { return false } - return host == allowedIP + hostIP := normalizeIP(net.ParseIP(host)) + if hostIP == nil { + return false + } + for _, item := range strings.Split(allowedIPs, ",") { + entry := strings.TrimSpace(item) + if strings.Contains(entry, "/") { + _, network, err := net.ParseCIDR(entry) + if err == nil && networkContainsIP(network, hostIP) { + return true + } + continue + } + + entryIP := normalizeIP(net.ParseIP(entry)) + if entryIP != nil && entryIP.Equal(hostIP) { + return true + } + } + return false +} + +func normalizeIP(ip net.IP) net.IP { + if ip == nil { + return nil + } + if v4 := ip.To4(); v4 != nil { + return v4 + } + return ip +} + +func networkContainsIP(network *net.IPNet, ip net.IP) bool { + if network == nil || ip == nil { + return false + } + if network.Contains(ip) { + return true + } + + networkIP := normalizeIP(network.IP) + normalizedIP := normalizeIP(ip) + if networkIP == nil || normalizedIP == nil { + return false + } + if len(networkIP) != len(normalizedIP) { + return false + } + + mask := network.Mask + if len(mask) != len(normalizedIP) { + return false + } + for i := range normalizedIP { + if normalizedIP[i]&mask[i] != networkIP[i]&mask[i] { + return false + } + } + return true } func closeWrite(conn net.Conn) { @@ -440,42 +516,9 @@ func expirationCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) { defer ticker.Stop() for range ticker.C { - noodles := db.GetAll() - for _, item := range noodles { - if !item.IsUp { - continue - } - if item.Expiration <= 0 { - if item.IsUp { - item.IsUp = false - noodleChannel <- item - } - if err := db.Delete(item.Id); err != nil { - log.Print(err) - } - continue - } - - item.Expiration -= time.Second - if item.Expiration <= 0 { - item.Expiration = 0 - if err := db.Update(item); err != nil { - log.Print(err) - continue - } - if item.IsUp { - item.IsUp = false - noodleChannel <- item - } - if err := db.Delete(item.Id); err != nil { - log.Print(err) - } - continue - } - - if err := db.Update(item); err != nil { - log.Print(err) - } + stopped := db.TickExpirations() + for _, item := range stopped { + noodleChannel <- item } } } diff --git a/internal/assets/templates/index.html b/internal/assets/templates/index.html index 4529ade..a8af477 100644 --- a/internal/assets/templates/index.html +++ b/internal/assets/templates/index.html @@ -11,8 +11,11 @@ background-image: url("/static/pub/long-banner.jpg"); background-repeat: no-repeat; background-size: cover; + background-position: center; position: relative; - width: 100%; + flex: 1 1 auto; + width: 100%; + min-height: 50px; } img.banner { height: 50px; @@ -24,6 +27,15 @@ table { width: 100%; } + .allow-from-options { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + margin-top: 0.35rem; + } + .allow-from-options .button { + height: 1.8rem; + } @@ -34,6 +46,15 @@ @@ -49,6 +70,7 @@ Dest Host/IP Expiration Status + Created By @@ -64,11 +86,15 @@ - + +
+ + +
@@ -76,6 +102,8 @@ + +
+ {{end}} @@ -114,11 +146,20 @@