Compare commits

..

4 Commits

Author SHA1 Message Date
f2a246ce6b Add auth-managed proxy UI improvements 2026-04-03 20:58:54 -04:00
7ed709ad3d Add UDP proxy support 2026-03-30 21:26:53 -04:00
9f90997bab Update README for proxy UI changes 2026-03-30 21:11:33 -04:00
0f3803b0e6 Add editable proxy controls to UI 2026-03-30 21:08:31 -04:00
11 changed files with 2021 additions and 75 deletions

View File

@ -3,8 +3,9 @@
`infinite-noodle` is a small Go service that combines: `infinite-noodle` is a small Go service that combines:
- a web UI for viewing configured TCP proxies - 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 Bitcask-backed data store for persisted proxy definitions
- a TCP proxy runner powered by `inet.af/tcpproxy` - a TCP proxy runner built with Go's standard `net` package
This README is written for working in a GitHub Codespace. This README is written for working in a GitHub Codespace.
@ -12,27 +13,40 @@ This README is written for working in a GitHub Codespace.
Each "noodle" is a TCP forwarding rule with: Each "noodle" is a TCP forwarding rule with:
- a name
- an allowed source list (`All`, one or more IPs, or CIDR ranges)
- a listen port - a listen port
- a destination host - a destination host
- a destination port - a destination port
- an expiration duration
- an up/down state - 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 basic web UI for listing and deleting them. 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.
## Current State ## Current State
The project is functional enough to: The project is functional enough to:
- start the web app - 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` - load stored proxy definitions from `./infinite.db`
- run active TCP proxies - create new noodles from the UI
- delete existing noodles from the UI - run active TCP and UDP proxies
- 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 with confirmation
What is not wired up yet: Current limitations:
- the add row in the UI is present, but create/save is not implemented - there is no REST API; management is currently through the server-rendered UI
- there is no REST API for creating noodles - protocols are limited to TCP and UDP
- proxy routing is TCP only in the current code path
## Running In A Codespace ## Running In A Codespace
@ -52,6 +66,14 @@ Default runtime settings:
Open the app in the browser from the forwarded port for `7878`. 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: You can also override the defaults:
```bash ```bash
@ -65,6 +87,36 @@ Available flags:
- `-data`: path to the Bitcask database directory - `-data`: path to the Bitcask database directory
- `-test`: seeds the database with sample noodles on startup - `-test`: seeds the database with sample noodles on startup
## UI Behavior
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`, 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`
- `Destination Host`
- `Expiration`: parsed as a Go `time.Duration` such as `30s`, `5m`, or `1h15m`
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 ## Codespaces Port Notes
In Codespaces, you will usually need to forward: In Codespaces, you will usually need to forward:
@ -85,11 +137,9 @@ Typical flow:
1. Run the app with `go run ./cmd/infinite-noodle` 1. Run the app with `go run ./cmd/infinite-noodle`
2. Start the test echo server on `127.0.0.1:6666` 2. Start the test echo server on `127.0.0.1:6666`
3. Ensure the database contains a noodle that listens on `5555` and forwards to `127.0.0.1:6666` 3. Add a noodle in the UI that listens on `5555` and forwards to `127.0.0.1:6666`
4. Run the test client, which connects to `127.0.0.1:5555` 4. Run the test client, which connects to `127.0.0.1:5555`
Because create is not implemented in the UI yet, you currently need to seed or insert noodle records another way.
## Building Binaries ## Building Binaries
The repo includes [`build.sh`](/data/project/go/src/infinite-noodle/build.sh), which builds binaries for: The repo includes [`build.sh`](/data/project/go/src/infinite-noodle/build.sh), which builds binaries for:
@ -108,13 +158,22 @@ sh build.sh
Artifacts are written under `./target/`. Artifacts are written under `./target/`.
To build only the Linux amd64 target:
```bash
GOOS=linux GOARCH=amd64 go build -buildvcs=false -o target/infinite-noodle.net-proxy ./cmd/infinite-noodle
```
## Important Files ## Important Files
- [`cmd/infinite-noodle/main.go`](/data/project/go/src/infinite-noodle/cmd/infinite-noodle/main.go): binary entrypoint and CLI flags - [`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/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/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/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/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 ## Verification Note

3
go.mod
View File

@ -3,14 +3,13 @@ module infinite-noodle
go 1.19 go 1.19
require ( require (
github.com/google/uuid v1.6.0
go.mills.io/bitcask/v2 v2.1.3 go.mills.io/bitcask/v2 v2.1.3
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9
) )
require ( require (
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 // indirect github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 // indirect
github.com/gofrs/flock v0.8.1 // indirect github.com/gofrs/flock v0.8.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.0.0 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.0.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattetti/filebuffer v1.0.1 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect

3
go.sum
View File

@ -1,6 +1,5 @@
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 h1:uHogIJ9bXH75ZYrXnVShHIyywFiUZ7OOabwd9Sfd8rw= github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 h1:uHogIJ9bXH75ZYrXnVShHIyywFiUZ7OOabwd9Sfd8rw=
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81/go.mod h1:6ZvnjTZX1LNo1oLpfaJK8h+MXqHxcBFBIwkgsv+xlv0= github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81/go.mod h1:6ZvnjTZX1LNo1oLpfaJK8h+MXqHxcBFBIwkgsv+xlv0=
github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@ -50,5 +49,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9 h1:zomTWJvjwLbKRgGameQtpK6DNFUbZ2oNJuWhgUkGp3M=
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9/go.mod h1:Tojt5kmHpDIR2jMojxzZK2w2ZR7OILODmUo2gaSwjrk=

View File

@ -2,14 +2,16 @@ package app
import ( import (
"fmt" "fmt"
"io"
"log" "log"
"net"
"net/http" "net/http"
"strings"
"sync"
"time" "time"
"infinite-noodle/internal/noodle" "infinite-noodle/internal/noodle"
"infinite-noodle/internal/web" "infinite-noodle/internal/web"
"inet.af/tcpproxy"
) )
type Config struct { type Config struct {
@ -26,16 +28,34 @@ func Run(cfg Config) error {
db := noodle.NewDatabase(cfg.DataPath) db := noodle.NewDatabase(cfg.DataPath)
defer db.Close() defer db.Close()
auth, err := web.NewAuth(db)
if err != nil {
return err
}
if err := auth.EnsureDefaultUser(); err != nil {
return err
}
if cfg.RunTest { if cfg.RunTest {
runTestSequence(db) runTestSequence(db)
} }
go systemCheck(db, noodleChannel) go systemCheck(db, noodleChannel)
go tcpProxify(noodleChannel) go expirationCheck(db, noodleChannel)
go proxify(db, noodleChannel)
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(web.StaticFiles())))) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(web.StaticFiles()))))
http.HandleFunc("/", web.HandleMain(db, &noodleChannel)) http.HandleFunc("/login", auth.HandleLogin())
http.HandleFunc("/delete", web.HandleDelete(db, &noodleChannel)) 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) log.Printf("Server starting on %s", listenAddr)
return http.ListenAndServe(listenAddr, nil) return http.ListenAndServe(listenAddr, nil)
@ -51,23 +71,22 @@ func systemCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) {
} }
} }
func startProxy(proxy *tcpproxy.Proxy) { func proxify(db *noodle.Database, noodleChannel chan noodle.Noodle) {
log.Print(proxy.Run()) noodleMap := make(map[string]managedProxy)
}
func tcpProxify(noodleChannel chan noodle.Noodle) {
noodleMap := make(map[string]*tcpproxy.Proxy)
for { for {
item := <-noodleChannel item := <-noodleChannel
_, running := noodleMap[item.Id] _, running := noodleMap[item.Id]
if item.IsUp && !running { if item.IsUp && !running {
var proxy tcpproxy.Proxy proxy, err := newProxy(item)
src := fmt.Sprintf("0.0.0.0:%d", item.ListenPort) if err != nil {
dst := fmt.Sprintf("%s:%d", item.DestHost, item.DestPort) log.Print(err)
log.Printf("Starting a noodle from %s to %s", src, dst) if _, updateErr := db.SetIsUp(item.Id, false); updateErr != nil {
proxy.AddRoute(src, tcpproxy.To(dst)) log.Print(updateErr)
noodleMap[item.Id] = &proxy }
go startProxy(&proxy) continue
}
noodleMap[item.Id] = proxy
go proxy.Run()
continue continue
} }
@ -76,6 +95,430 @@ func tcpProxify(noodleChannel chan noodle.Noodle) {
if err := noodleMap[item.Id].Close(); err != nil { if err := noodleMap[item.Id].Close(); err != nil {
log.Print(err) log.Print(err)
} }
delete(noodleMap, item.Id)
}
}
}
type managedProxy interface {
Run()
Close() error
}
func newProxy(item noodle.Noodle) (managedProxy, error) {
listenAddr := fmt.Sprintf("0.0.0.0:%d", item.ListenPort)
targetAddr := fmt.Sprintf("%s:%d", item.DestHost, item.DestPort)
proto := normalizeProto(item.Proto)
log.Printf("Starting a %s noodle from %s to %s with source=%s", proto, listenAddr, targetAddr, item.Src)
switch proto {
case "UDP":
return newUDPNoodleProxy(listenAddr, targetAddr, item.Src)
default:
return newTCPNoodleProxy(listenAddr, targetAddr, item.Src)
}
}
func normalizeProto(proto string) string {
switch strings.ToUpper(strings.TrimSpace(proto)) {
case "UDP":
return "UDP"
default:
return "TCP"
}
}
type tcpNoodleProxy struct {
listenAddr string
targetAddr string
allowedIP string
listener net.Listener
mu sync.Mutex
conns map[net.Conn]struct{}
}
func newTCPNoodleProxy(listenAddr, targetAddr, allowedIP string) (*tcpNoodleProxy, error) {
ln, err := net.Listen("tcp", listenAddr)
if err != nil {
return nil, err
}
return &tcpNoodleProxy{
listenAddr: listenAddr,
targetAddr: targetAddr,
allowedIP: allowedIP,
listener: ln,
conns: make(map[net.Conn]struct{}),
}, nil
}
func (p *tcpNoodleProxy) Run() {
for {
conn, err := p.listener.Accept()
if err != nil {
if isClosedNetworkError(err) {
return
}
log.Printf("Accept failed for %s: %v", p.listenAddr, err)
return
}
go p.handleConn(conn)
}
}
func (p *tcpNoodleProxy) handleConn(src net.Conn) {
if !allowSource(p.allowedIP, src.RemoteAddr()) {
log.Printf("Rejected noodle connection from %s; allowed source is %s", src.RemoteAddr().String(), p.allowedIP)
src.Close()
return
}
dst, err := net.Dial("tcp", p.targetAddr)
if err != nil {
log.Printf("Dial failed from %s to %s: %v", p.listenAddr, p.targetAddr, err)
src.Close()
return
}
p.trackConn(src)
p.trackConn(dst)
defer p.untrackAndClose(src)
defer p.untrackAndClose(dst)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
if _, err := io.Copy(dst, src); err != nil && !isClosedNetworkError(err) {
log.Printf("Proxy copy src->dst failed for %s to %s: %v", p.listenAddr, p.targetAddr, err)
}
closeWrite(dst)
}()
go func() {
defer wg.Done()
if _, err := io.Copy(src, dst); err != nil && !isClosedNetworkError(err) {
log.Printf("Proxy copy dst->src failed for %s to %s: %v", p.targetAddr, p.listenAddr, err)
}
closeWrite(src)
}()
wg.Wait()
}
func (p *tcpNoodleProxy) Close() error {
err := p.listener.Close()
p.mu.Lock()
defer p.mu.Unlock()
for conn := range p.conns {
_ = conn.Close()
}
return err
}
func (p *tcpNoodleProxy) trackConn(conn net.Conn) {
p.mu.Lock()
defer p.mu.Unlock()
p.conns[conn] = struct{}{}
}
func (p *tcpNoodleProxy) untrackAndClose(conn net.Conn) {
p.mu.Lock()
delete(p.conns, conn)
p.mu.Unlock()
_ = conn.Close()
}
type udpNoodleProxy struct {
listenAddr string
targetAddr string
allowedIP string
conn *net.UDPConn
mu sync.Mutex
sessions map[string]*udpSession
closed bool
}
type udpSession struct {
clientAddr *net.UDPAddr
backend *net.UDPConn
lastSeen time.Time
}
const udpSessionIdleTimeout = 2 * time.Minute
func newUDPNoodleProxy(listenAddr, targetAddr, allowedIP string) (*udpNoodleProxy, error) {
addr, err := net.ResolveUDPAddr("udp", listenAddr)
if err != nil {
return nil, err
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
return nil, err
}
return &udpNoodleProxy{
listenAddr: listenAddr,
targetAddr: targetAddr,
allowedIP: allowedIP,
conn: conn,
sessions: make(map[string]*udpSession),
}, nil
}
func (p *udpNoodleProxy) Run() {
go p.cleanupIdleSessions()
buf := make([]byte, 65535)
for {
n, clientAddr, err := p.conn.ReadFromUDP(buf)
if err != nil {
if isClosedNetworkError(err) {
return
}
log.Printf("UDP read failed for %s: %v", p.listenAddr, err)
return
}
if !allowSource(p.allowedIP, clientAddr) {
log.Printf("Rejected noodle datagram from %s; allowed source is %s", clientAddr.String(), p.allowedIP)
continue
}
session, err := p.sessionFor(clientAddr)
if err != nil {
log.Printf("UDP session setup failed for %s to %s: %v", clientAddr.String(), p.targetAddr, err)
continue
}
payload := append([]byte(nil), buf[:n]...)
if _, err := session.backend.Write(payload); err != nil && !isClosedNetworkError(err) {
log.Printf("UDP write failed from %s to %s: %v", clientAddr.String(), p.targetAddr, err)
p.removeSession(clientAddr.String(), true)
}
}
}
func (p *udpNoodleProxy) sessionFor(clientAddr *net.UDPAddr) (*udpSession, error) {
key := clientAddr.String()
p.mu.Lock()
if session, ok := p.sessions[key]; ok {
session.lastSeen = time.Now()
p.mu.Unlock()
return session, nil
}
p.mu.Unlock()
targetAddr, err := net.ResolveUDPAddr("udp", p.targetAddr)
if err != nil {
return nil, err
}
backend, err := net.DialUDP("udp", nil, targetAddr)
if err != nil {
return nil, err
}
session := &udpSession{
clientAddr: clientAddr,
backend: backend,
lastSeen: time.Now(),
}
p.mu.Lock()
if existing, ok := p.sessions[key]; ok {
p.mu.Unlock()
_ = backend.Close()
return existing, nil
}
p.sessions[key] = session
p.mu.Unlock()
go p.relayBackend(session)
return session, nil
}
func (p *udpNoodleProxy) relayBackend(session *udpSession) {
buf := make([]byte, 65535)
for {
n, err := session.backend.Read(buf)
if err != nil {
if !isClosedNetworkError(err) {
log.Printf("UDP backend read failed for %s: %v", p.targetAddr, err)
}
p.removeSession(session.clientAddr.String(), false)
return
}
if _, err := p.conn.WriteToUDP(buf[:n], session.clientAddr); err != nil {
if !isClosedNetworkError(err) {
log.Printf("UDP client write failed for %s: %v", session.clientAddr.String(), err)
}
p.removeSession(session.clientAddr.String(), true)
return
}
p.mu.Lock()
if current, ok := p.sessions[session.clientAddr.String()]; ok {
current.lastSeen = time.Now()
}
p.mu.Unlock()
}
}
func (p *udpNoodleProxy) cleanupIdleSessions() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if p.isClosed() {
return
}
cutoff := time.Now().Add(-udpSessionIdleTimeout)
var expired []string
p.mu.Lock()
for key, session := range p.sessions {
if session.lastSeen.Before(cutoff) {
expired = append(expired, key)
}
}
p.mu.Unlock()
for _, key := range expired {
p.removeSession(key, true)
}
}
}
func (p *udpNoodleProxy) removeSession(key string, closeBackend bool) {
p.mu.Lock()
session, ok := p.sessions[key]
if ok {
delete(p.sessions, key)
}
p.mu.Unlock()
if ok && closeBackend {
_ = session.backend.Close()
}
}
func (p *udpNoodleProxy) isClosed() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.closed
}
func (p *udpNoodleProxy) Close() error {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return nil
}
p.closed = true
sessions := p.sessions
p.sessions = make(map[string]*udpSession)
conn := p.conn
p.mu.Unlock()
for _, session := range sessions {
_ = session.backend.Close()
}
if conn == nil {
return nil
}
return conn.Close()
}
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
}
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) {
if tcpConn, ok := conn.(*net.TCPConn); ok {
_ = tcpConn.CloseWrite()
}
}
func isClosedNetworkError(err error) bool {
if err == nil {
return false
}
if net.ErrClosed != nil && err == net.ErrClosed {
return true
}
return strings.Contains(err.Error(), "use of closed network connection")
}
func expirationCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for range ticker.C {
stopped := db.TickExpirations()
for _, item := range stopped {
noodleChannel <- item
} }
} }
} }
@ -85,11 +528,12 @@ func runTestSequence(db *noodle.Database) {
item := noodle.Noodle{ item := noodle.Noodle{
Id: db.MakeID(), Id: db.MakeID(),
Name: "Name_Test", Name: "Name_Test",
Proto: "Proto_Test", Proto: "TCP",
Src: "All",
ListenPort: 1080 + i, ListenPort: 1080 + i,
DestPort: 22, DestPort: 22,
DestHost: "localhost", DestHost: "localhost",
Expiration: time.Now().Second(), Expiration: time.Duration(time.Now().Second()) * time.Second,
IsUp: true, IsUp: true,
} }
log.Printf("Test noodle=%v", item) log.Printf("Test noodle=%v", item)

View File

@ -11,8 +11,11 @@
background-image: url("/static/pub/long-banner.jpg"); background-image: url("/static/pub/long-banner.jpg");
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
background-position: center;
position: relative; position: relative;
flex: 1 1 auto;
width: 100%; width: 100%;
min-height: 50px;
} }
img.banner { img.banner {
height: 50px; height: 50px;
@ -24,6 +27,15 @@
table { table {
width: 100%; width: 100%;
} }
.allow-from-options {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
margin-top: 0.35rem;
}
.allow-from-options .button {
height: 1.8rem;
}
</style> </style>
</head> </head>
@ -34,6 +46,15 @@
</div> </div>
<div class="banner-container"> <div class="banner-container">
<!-- <img class="banner-long" src="/static/pub/long-banner.jpg"/> --> <!-- <img class="banner-long" src="/static/pub/long-banner.jpg"/> -->
<div class="navbar-end pr-4">
<div class="navbar-item">
<span class="has-text-white mr-3">Signed in as {{.Username}}</span>
<a class="button is-small is-warning mr-2" href="/users">{{if eq .Role "admin"}}Users{{else}}Account{{end}}</a>
<form method="post" action="/logout" autocomplete="off">
<button class="button is-small is-light" type="submit">Logout</button>
</form>
</div>
</div>
</div> </div>
</nav> </nav>
@ -43,49 +64,79 @@
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Proto</th> <th>Proto</th>
<th>Allow From</th>
<th>Listening Port</th> <th>Listening Port</th>
<th>Dest Port</th> <th>Dest Port</th>
<th>Dest Host/IP</th> <th>Dest Host/IP</th>
<th>Expiration</th> <th>Expiration</th>
<th>Status</th> <th>Status</th>
<th>Created By</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><input class="input is-link is-small" type="text" placeholder="Name"/></td> <td><input class="input is-link is-small" type="text" form="add-noodle" name="name" placeholder="Name" autocomplete="off" required/></td>
<td>TCP</td> <td>
<td><input class="input is-link is-small" type="text" placeholder="Listen Port"/></td> <div class="select is-small is-link">
<td><input class="input is-link is-small" type="text" placeholder="Destination Port"/></td> <select form="add-noodle" name="proto" autocomplete="off" required>
<td><input class="input is-link is-small" type="text" placeholder="Destination Host"/></td> <option value="TCP">TCP</option>
<td>Expiration</td> <option value="UDP">UDP</option>
</select>
</div>
</td>
<td>
<input class="input is-link is-small" type="text" form="add-noodle" name="src" list="allow-from-options" placeholder="All, comma-separated IPs, or CIDRs" value="All" autocomplete="off" required/>
<datalist id="allow-from-options">
<option value="All"></option>
<option value="{{.ClientIP}}"></option>
</datalist>
<div class="allow-from-options">
<button class="button is-small is-white" type="button" onclick="setAllowFromValue('All')">All</button>
<button class="button is-small is-white" type="button" onclick="setAllowFromValue('{{.ClientIP}}')">{{.ClientIP}}</button>
</div>
</td>
<td><input class="input is-link is-small" type="text" form="add-noodle" name="listen_port" placeholder="Listen Port" autocomplete="off" required/></td>
<td><input class="input is-link is-small" type="text" form="add-noodle" name="dest_port" placeholder="Destination Port" autocomplete="off" required/></td>
<td><input class="input is-link is-small" type="text" form="add-noodle" name="dest_host" placeholder="Destination Host" autocomplete="off" required/></td>
<td><input class="input is-link is-small" type="text" form="add-noodle" name="expiration" placeholder="30m, 1h15m" autocomplete="off" required/></td>
<td> <td>
</td> </td>
<td> <td>
<button class="button"> </td>
<td>
<form id="add-noodle" method="post" action="/add" autocomplete="off">
<button class="button" type="submit">
<span class="icon has-text-success"><i class="fas fa-plus"></i></span> <span class="icon has-text-success"><i class="fas fa-plus"></i></span>
</button> </button>
</form>
</td> </td>
</tr> </tr>
{{range .}} {{range .Noodles}}
<tr> <tr>
<td>{{.Name}}</td> <td>{{.Name}}</td>
<td>{{.Proto}}</td> <td>{{.Proto}}</td>
<td>{{.Src}}</td>
<td>{{.ListenPort}}</td> <td>{{.ListenPort}}</td>
<td>{{.DestPort}}</td> <td>{{.DestPort}}</td>
<td>{{.DestHost}}</td> <td>{{.DestHost}}</td>
<td>{{.Expiration}}</td> <td data-expiration-ms="{{.Expiration.Milliseconds}}" data-is-up="{{.IsUp}}">{{.Expiration}}</td>
<td> <td>
{{if .IsUp}} <form method="post" action="/toggle" autocomplete="off">
<span class="icon has-text-success"><i class="fas fa-check-square"></i></span> <input type="hidden" name="id" value="{{.Id}}"/>
{{ else }} <label>
<span class="icon has-text-danger"><i class="fas fa-ban"></i></span> <input type="checkbox" name="is_up" onchange="this.form.submit()" {{if .IsUp}}checked{{end}}/>
{{ end }} </label>
</form>
</td> </td>
<td>{{.CreatedBy}}</td>
<td> <td>
<a href="/delete?id={{.Id}}"> <form method="post" action="/delete" autocomplete="off" onsubmit="return confirm('Delete this proxy?');">
<input type="hidden" name="id" value="{{.Id}}"/>
<button class="button is-white" type="submit" aria-label="Delete noodle">
<span class="icon has-text-danger"><i class="fas fa-minus"></i></span> <span class="icon has-text-danger"><i class="fas fa-minus"></i></span>
</a> </button>
</form>
</td> </td>
</tr> </tr>
{{end}} {{end}}
@ -95,10 +146,72 @@
<footer class="footer"> <footer class="footer">
<div class="content has-text-centered"> <div class="content has-text-centered">
<p> <p>
<strong>Infinite-Noodles Network Proxy</strong> - &copy; 2025 Jimmy Allen <strong>Infinite-Noodles Network Proxy</strong>
</p> </p>
</div> </div>
</footer> </footer>
<script>
function setAllowFromValue(value) {
const input = document.querySelector('input[name="src"]');
if (!input) {
return;
}
input.value = value;
input.focus();
}
function formatDuration(ms) {
if (ms <= 0) {
return "0s";
}
let totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
totalSeconds -= hours * 3600;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const parts = [];
if (hours > 0) {
parts.push(hours + "h");
}
if (minutes > 0 || hours > 0) {
parts.push(minutes + "m");
}
parts.push(seconds + "s");
return parts.join("");
}
function updateExpirationCountdowns() {
const expirationCells = document.querySelectorAll("[data-expiration-ms]");
expirationCells.forEach((cell) => {
if (cell.dataset.isUp !== "true") {
return;
}
const nextValue = Number(cell.dataset.expirationMs) - 1000;
const clampedValue = Math.max(nextValue, 0);
cell.dataset.expirationMs = String(clampedValue);
if (clampedValue === 0) {
const row = cell.closest("tr");
if (row) {
row.remove();
}
return;
}
cell.textContent = formatDuration(clampedValue);
});
}
document.addEventListener("DOMContentLoaded", () => {
const expirationCells = document.querySelectorAll("[data-expiration-ms]");
expirationCells.forEach((cell) => {
cell.textContent = formatDuration(Number(cell.dataset.expirationMs));
});
window.setInterval(updateExpirationCountdowns, 1000);
});
</script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html data-theme="dark">
<head>
<title>Infinite-Noodles Login</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/pub/bulma.min.css">
<link rel="stylesheet" href="/static/pub/all.min.css">
<style>
body {
min-height: 100vh;
background-color: #000;
color: #f5f5f5;
}
.banner-container {
background-image: url("/static/pub/long-banner.jpg");
background-repeat: no-repeat;
background-size: cover;
background-position: center;
position: relative;
flex: 1 1 auto;
width: 100%;
min-height: 50px;
}
img.banner {
height: 50px;
}
.login-shell {
min-height: calc(100vh - 52px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 440px;
background: #111;
border-radius: 18px;
border: 1px solid #2f2f2f;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.login-body {
padding: 2rem;
}
.login-body .label,
.login-body .help,
.login-body p,
.login-body strong {
color: #f5f5f5;
}
.login-body .input {
background: #1b1b1b;
border-color: #3d3d3d;
color: #f5f5f5;
}
.login-body .input::placeholder {
color: #9a9a9a;
}
</style>
</head>
<body>
<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<img class="banner" src="/static/pub/logo.jpg"/>
</div>
<div class="banner-container"></div>
</nav>
<main class="login-shell">
<section class="login-card">
<div class="login-body">
{{if .Error}}
<article class="message is-danger">
<div class="message-body">{{.Error}}</div>
</article>
{{end}}
<form method="post" action="/login" autocomplete="off">
<div class="field">
<label class="label">Username</label>
<div class="control has-icons-left">
<input class="input" type="text" name="username" required autofocus>
<span class="icon is-small is-left"><i class="fas fa-user"></i></span>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control has-icons-left">
<input class="input" type="password" name="password" required>
<span class="icon is-small is-left"><i class="fas fa-lock"></i></span>
</div>
</div>
<div class="field">
<button class="button is-link is-fullwidth" type="submit">Login</button>
</div>
</form>
</div>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,234 @@
<!DOCTYPE html>
<html data-theme="dark">
<head>
<title>User Configuration</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/pub/bulma.min.css">
<link rel="stylesheet" href="/static/pub/all.min.css">
<style>
body {
min-height: 100vh;
background-color: #000;
color: #f5f5f5;
}
.banner-container {
background-image: url("/static/pub/long-banner.jpg");
background-repeat: no-repeat;
background-size: cover;
background-position: center;
position: relative;
flex: 1 1 auto;
width: 100%;
min-height: 50px;
}
img.banner {
height: 50px;
}
.page-shell {
padding: 5rem 1.25rem 3rem;
}
.page-card {
background: #111;
border-radius: 18px;
border: 1px solid #2f2f2f;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
}
.title,
.subtitle,
.label,
.table,
.table th,
.table td,
strong,
p {
color: #f5f5f5;
}
.table {
background: transparent;
font-size: 0.9rem;
}
.table th,
.table td {
border-color: #2f2f2f;
vertical-align: top;
padding: 0.5rem 0.6rem;
}
.input,
.select select {
background: #1b1b1b;
border-color: #3d3d3d;
color: #f5f5f5;
}
.input::placeholder {
color: #9a9a9a;
}
.user-actions .button,
.user-actions .input,
.user-actions .select select {
font-size: 0.8rem;
height: 2rem;
}
.user-actions .field:not(:last-child) {
margin-bottom: 0.4rem;
}
</style>
</head>
<body>
<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<img class="banner" src="/static/pub/logo.jpg"/>
</div>
<div class="banner-container"></div>
</nav>
<section class="page-shell">
<div class="container is-max-desktop">
<div class="level mb-5">
<div class="level-left">
<div>
<h1 class="title is-3">User Configuration</h1>
<p class="subtitle is-6">Signed in as {{.Username}} ({{.Role}})</p>
</div>
</div>
<div class="level-right">
<div class="buttons">
<a class="button is-light" href="/">Back to Proxies</a>
<form method="post" action="/logout" autocomplete="off">
<button class="button is-dark" type="submit">Logout</button>
</form>
</div>
</div>
</div>
{{if .Error}}
<article class="message is-danger">
<div class="message-body">{{.Error}}</div>
</article>
{{end}}
<div class="columns is-variable is-5">
<div class="column is-5">
{{if ne .Role "admin"}}
<div class="box page-card">
<h2 class="title is-5">Change Your Password</h2>
<form method="post" action="/users/password" autocomplete="off">
<input type="hidden" name="username" value="{{.Username}}">
<div class="field">
<label class="label">New Password</label>
<div class="control">
<input class="input" type="password" name="password" required>
</div>
</div>
<div class="field">
<label class="label">Confirm Password</label>
<div class="control">
<input class="input" type="password" name="confirm_password" required>
</div>
</div>
<button class="button is-link is-fullwidth" type="submit">Update Password</button>
</form>
</div>
{{end}}
{{if eq .Role "admin"}}
<div class="box page-card">
<h2 class="title is-5">Add User</h2>
<form method="post" action="/users/add" autocomplete="off">
<div class="field">
<label class="label">Username</label>
<div class="control">
<input class="input" type="text" name="username" required>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" type="password" name="password" required>
</div>
</div>
<div class="field">
<label class="label">Role</label>
<div class="control">
<div class="select is-fullwidth">
<select name="role">
<option value="regular">regular</option>
<option value="admin">admin</option>
</select>
</div>
</div>
</div>
<button class="button is-link is-fullwidth" type="submit">Create User</button>
</form>
</div>
{{end}}
</div>
<div class="column">
{{if eq .Role "admin"}}
<div class="box page-card">
<h2 class="title is-5">Existing Users</h2>
<table class="table is-fullwidth is-striped is-hoverable is-narrow">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.Username}}</td>
<td>{{.Role}}</td>
<td>{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</td>
<td class="user-actions">
<form method="post" action="/users/role" autocomplete="off" class="mb-2">
<input type="hidden" name="username" value="{{.Username}}">
<div class="field has-addons">
<div class="control is-expanded">
<div class="select is-small is-fullwidth">
<select name="role">
<option value="regular" {{if eq .Role "regular"}}selected{{end}}>regular</option>
<option value="admin" {{if eq .Role "admin"}}selected{{end}}>admin</option>
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-info" type="submit">Update</button>
</div>
</div>
</form>
<form method="post" action="/users/password" autocomplete="off" class="mb-2">
<input type="hidden" name="username" value="{{.Username}}">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-small" type="password" name="password" placeholder="New password" required>
</div>
<div class="control">
<button class="button is-small is-warning" type="submit">Set Password</button>
</div>
</div>
</form>
<form method="post" action="/users/delete" autocomplete="off" onsubmit="return confirm('Delete user {{.Username}}?');">
<input type="hidden" name="username" value="{{.Username}}">
<button class="button is-small is-danger is-light" type="submit">Delete User</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="box page-card">
<h2 class="title is-5">Account</h2>
<p class="mb-2"><strong>Username:</strong> {{.Username}}</p>
<p><strong>Role:</strong> {{.Role}}</p>
</div>
{{end}}
</div>
</div>
</div>
</section>
</body>
</html>

View File

@ -1,15 +1,24 @@
package noodle package noodle
import ( import (
"errors"
"fmt"
"log" "log"
"sync"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"go.mills.io/bitcask/v2" "go.mills.io/bitcask/v2"
) )
var ErrUserNotFound = errors.New("user not found")
var ErrLastAdminRemoval = errors.New("cannot remove the last admin")
type Database struct { type Database struct {
connection *bitcask.Bitcask connection *bitcask.Bitcask
Handle *bitcask.Collection Handle *bitcask.Collection
Users *bitcask.Collection
mu sync.Mutex
} }
func NewDatabase(path string) *Database { func NewDatabase(path string) *Database {
@ -20,6 +29,7 @@ func NewDatabase(path string) *Database {
return &Database{ return &Database{
connection: db, connection: db,
Handle: db.Collection("noodles"), Handle: db.Collection("noodles"),
Users: db.Collection("users"),
} }
} }
@ -33,6 +43,9 @@ func (db *Database) MakeID() string {
} }
func (db *Database) GetAll() []Noodle { func (db *Database) GetAll() []Noodle {
db.mu.Lock()
defer db.mu.Unlock()
var data []Noodle var data []Noodle
if err := db.Handle.List(&data); err != nil { if err := db.Handle.List(&data); err != nil {
log.Print(err) log.Print(err)
@ -42,6 +55,9 @@ func (db *Database) GetAll() []Noodle {
} }
func (db *Database) GetAllGeneric() []interface{} { func (db *Database) GetAllGeneric() []interface{} {
db.mu.Lock()
defer db.mu.Unlock()
var data []interface{} var data []interface{}
if err := db.Handle.List(&data); err != nil { if err := db.Handle.List(&data); err != nil {
log.Print(err) log.Print(err)
@ -51,6 +67,9 @@ func (db *Database) GetAllGeneric() []interface{} {
} }
func (db *Database) Get(id string) Noodle { func (db *Database) Get(id string) Noodle {
db.mu.Lock()
defer db.mu.Unlock()
var item Noodle var item Noodle
log.Printf("Looking up noodle key='%s'", id) log.Printf("Looking up noodle key='%s'", id)
log.Printf("key='%s' exists=%t", id, db.Handle.Has(id)) log.Printf("key='%s' exists=%t", id, db.Handle.Has(id))
@ -62,6 +81,20 @@ func (db *Database) Get(id string) Noodle {
} }
func (db *Database) Add(item Noodle) error { func (db *Database) Add(item Noodle) error {
db.mu.Lock()
defer db.mu.Unlock()
if err := db.Handle.Add(item.Id, item); err != nil {
log.Print(err)
return err
}
return nil
}
func (db *Database) Update(item Noodle) error {
db.mu.Lock()
defer db.mu.Unlock()
if err := db.Handle.Add(item.Id, item); err != nil { if err := db.Handle.Add(item.Id, item); err != nil {
log.Print(err) log.Print(err)
return err return err
@ -70,6 +103,9 @@ func (db *Database) Add(item Noodle) error {
} }
func (db *Database) Delete(id string) error { func (db *Database) Delete(id string) error {
db.mu.Lock()
defer db.mu.Unlock()
if err := db.Handle.Delete(id); err != nil { if err := db.Handle.Delete(id); err != nil {
log.Print(err) log.Print(err)
return err return err
@ -80,3 +116,248 @@ func (db *Database) Delete(id string) error {
func (db *Database) Close() error { func (db *Database) Close() error {
return db.connection.Close() return db.connection.Close()
} }
func (db *Database) GetAllUsers() []User {
db.mu.Lock()
defer db.mu.Unlock()
var data []User
if err := db.Users.List(&data); err != nil {
log.Print(err)
return nil
}
return data
}
func (db *Database) GetUserByUsername(username string) (User, error) {
db.mu.Lock()
defer db.mu.Unlock()
var data []User
if err := db.Users.List(&data); err != nil {
log.Print(err)
return User{}, err
}
for _, user := range data {
if user.Username == username {
return user, nil
}
}
return User{}, ErrUserNotFound
}
func (db *Database) AddUser(user User) error {
db.mu.Lock()
defer db.mu.Unlock()
var data []User
if err := db.Users.List(&data); err != nil {
log.Print(err)
return err
}
for _, existing := range data {
if existing.Username == user.Username {
return fmt.Errorf("user %q already exists", user.Username)
}
}
if err := db.Users.Add(user.Id, user); err != nil {
log.Print(err)
return err
}
return nil
}
func (db *Database) UpdateUser(user User) error {
db.mu.Lock()
defer db.mu.Unlock()
if err := db.Users.Add(user.Id, user); err != nil {
log.Print(err)
return err
}
return nil
}
func (db *Database) SetUserRole(username, role string) (User, error) {
db.mu.Lock()
defer db.mu.Unlock()
var data []User
if err := db.Users.List(&data); err != nil {
log.Print(err)
return User{}, err
}
var target User
found := false
adminCount := 0
for _, user := range data {
if user.Role == UserRoleAdmin {
adminCount++
}
if user.Username == username {
target = user
found = true
}
}
if !found {
return User{}, ErrUserNotFound
}
if target.Role == UserRoleAdmin && role != UserRoleAdmin && adminCount <= 1 {
return User{}, ErrLastAdminRemoval
}
target.Role = role
if err := db.Users.Add(target.Id, target); err != nil {
log.Print(err)
return User{}, err
}
return target, nil
}
func (db *Database) SetUserPassword(username, passwordHash string) (User, error) {
db.mu.Lock()
defer db.mu.Unlock()
var data []User
if err := db.Users.List(&data); err != nil {
log.Print(err)
return User{}, err
}
for _, user := range data {
if user.Username != username {
continue
}
user.PasswordHash = passwordHash
if err := db.Users.Add(user.Id, user); err != nil {
log.Print(err)
return User{}, err
}
return user, nil
}
return User{}, ErrUserNotFound
}
func (db *Database) DeleteUser(username string) (User, error) {
db.mu.Lock()
defer db.mu.Unlock()
var data []User
if err := db.Users.List(&data); err != nil {
log.Print(err)
return User{}, err
}
var target User
found := false
adminCount := 0
for _, user := range data {
if user.Role == UserRoleAdmin {
adminCount++
}
if user.Username == username {
target = user
found = true
}
}
if !found {
return User{}, ErrUserNotFound
}
if target.Role == UserRoleAdmin && adminCount <= 1 {
return User{}, ErrLastAdminRemoval
}
if err := db.Users.Delete(target.Id); err != nil {
log.Print(err)
return User{}, err
}
return target, nil
}
func (db *Database) SetIsUp(id string, isUp bool) (Noodle, error) {
db.mu.Lock()
defer db.mu.Unlock()
var item Noodle
if err := db.Handle.Get(id, &item); err != nil {
log.Print(err)
return Noodle{}, err
}
item.IsUp = isUp
if err := db.Handle.Add(item.Id, item); err != nil {
log.Print(err)
return Noodle{}, err
}
return item, nil
}
func (db *Database) DeleteByID(id string) (Noodle, error) {
db.mu.Lock()
defer db.mu.Unlock()
var item Noodle
if err := db.Handle.Get(id, &item); err != nil {
log.Print(err)
return Noodle{}, err
}
if err := db.Handle.Delete(id); err != nil {
log.Print(err)
return Noodle{}, err
}
return item, nil
}
func (db *Database) TickExpirations() []Noodle {
db.mu.Lock()
defer db.mu.Unlock()
var noodles []Noodle
if err := db.Handle.List(&noodles); err != nil {
log.Print(err)
return nil
}
var stopped []Noodle
for _, item := range noodles {
if !item.IsUp {
continue
}
if item.Expiration <= 0 {
item.IsUp = false
stopped = append(stopped, item)
if err := db.Handle.Delete(item.Id); err != nil {
log.Print(err)
}
continue
}
item.Expiration -= time.Second
if item.Expiration <= 0 {
item.Expiration = 0
item.IsUp = false
stopped = append(stopped, item)
if err := db.Handle.Delete(item.Id); err != nil {
log.Print(err)
}
continue
}
if err := db.Handle.Add(item.Id, item); err != nil {
log.Print(err)
}
}
return stopped
}

View File

@ -1,12 +1,29 @@
package noodle package noodle
import "time"
const (
UserRoleRegular = "regular"
UserRoleAdmin = "admin"
)
type Noodle struct { type Noodle struct {
Id string Id string
Name string Name string
Proto string Proto string
Src string
ListenPort int ListenPort int
DestPort int DestPort int
DestHost string DestHost string
Expiration int Expiration time.Duration
IsUp bool IsUp bool
CreatedBy string
}
type User struct {
Id string
Username string
Role string
PasswordHash string
CreatedAt time.Time
} }

278
internal/web/auth.go Normal file
View File

@ -0,0 +1,278 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"strings"
"time"
"infinite-noodle/internal/assets"
"infinite-noodle/internal/noodle"
)
const sessionCookieName = "infinite_noodle_session"
var errInvalidCredentials = errors.New("invalid credentials")
type Auth struct {
db *noodle.Database
secret []byte
}
type loginPageData struct {
Error string
}
type authUser struct {
Username string
Role string
}
func NewAuth(db *noodle.Database) (*Auth, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, err
}
return &Auth{
db: db,
secret: secret,
}, nil
}
func HashPassword(password string) string {
sum := sha256.Sum256([]byte(password))
return hex.EncodeToString(sum[:])
}
func (a *Auth) EnsureDefaultUser() error {
users := a.db.GetAllUsers()
if len(users) == 0 {
user := noodle.User{
Id: a.db.MakeID(),
Username: "admin",
Role: noodle.UserRoleAdmin,
PasswordHash: HashPassword("admin"),
CreatedAt: time.Now().UTC(),
}
if err := a.db.AddUser(user); err != nil {
return err
}
log.Printf("Created default user username=%q password=%q", user.Username, "admin")
return nil
}
var hasAdmin bool
for _, user := range users {
updated := false
if user.Role == "" {
user.Role = noodle.UserRoleRegular
updated = true
}
if user.Username == "admin" {
user.Role = noodle.UserRoleAdmin
updated = true
}
if updated {
if err := a.db.UpdateUser(user); err != nil {
return err
}
}
if user.Role == noodle.UserRoleAdmin {
hasAdmin = true
}
}
if hasAdmin {
return nil
}
user := noodle.User{
Id: a.db.MakeID(),
Username: "admin",
Role: noodle.UserRoleAdmin,
PasswordHash: HashPassword("admin"),
CreatedAt: time.Now().UTC(),
}
if err := a.db.AddUser(user); err != nil {
return err
}
log.Printf("Created fallback admin user username=%q password=%q", user.Username, "admin")
return nil
}
func (a *Auth) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if _, ok := a.CurrentUser(req); !ok {
http.Redirect(w, req, "/login", http.StatusSeeOther)
return
}
next(w, req)
}
}
func (a *Auth) RequireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
user, ok := a.CurrentUser(req)
if !ok {
http.Redirect(w, req, "/login", http.StatusSeeOther)
return
}
if user.Role != noodle.UserRoleAdmin {
http.Error(w, "admin access required", http.StatusForbidden)
return
}
next(w, req)
}
}
func (a *Auth) CurrentUser(req *http.Request) (authUser, bool) {
cookie, err := req.Cookie(sessionCookieName)
if err != nil {
return authUser{}, false
}
username, ok := a.verifyCookieValue(cookie.Value)
if !ok {
return authUser{}, false
}
user, err := a.db.GetUserByUsername(username)
if err != nil || user.Username == "" {
return authUser{}, false
}
role := user.Role
if role == "" {
role = noodle.UserRoleRegular
}
return authUser{
Username: user.Username,
Role: role,
}, true
}
func (a *Auth) HandleLogin() http.HandlerFunc {
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
if err != nil {
log.Fatalf("Error parsing templates: %v", err)
}
return func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
if _, ok := a.CurrentUser(req); ok {
http.Redirect(w, req, "/", http.StatusSeeOther)
return
}
if err := tmpl.ExecuteTemplate(w, "login.html", loginPageData{}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
if err := req.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := strings.TrimSpace(req.FormValue("username"))
password := req.FormValue("password")
if username == "" || password == "" {
w.WriteHeader(http.StatusUnauthorized)
if err := tmpl.ExecuteTemplate(w, "login.html", loginPageData{Error: "Username and password are required."}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if err := a.Login(w, username, password); err != nil {
w.WriteHeader(http.StatusUnauthorized)
if err := tmpl.ExecuteTemplate(w, "login.html", loginPageData{Error: "Invalid username or password."}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
http.Redirect(w, req, "/", http.StatusSeeOther)
}
}
func (a *Auth) HandleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
a.ClearSession(w)
http.Redirect(w, req, "/login", http.StatusSeeOther)
}
}
func (a *Auth) Login(w http.ResponseWriter, username, password string) error {
user, err := a.db.GetUserByUsername(username)
if err != nil {
return errInvalidCredentials
}
if user.PasswordHash != HashPassword(password) {
return errInvalidCredentials
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: a.cookieValue(username),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int((24 * time.Hour).Seconds()),
})
return nil
}
func (a *Auth) ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func (a *Auth) cookieValue(username string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(username))
sig := mac.Sum(nil)
payload := fmt.Sprintf("%s:%s", username, hex.EncodeToString(sig))
return base64.RawURLEncoding.EncodeToString([]byte(payload))
}
func (a *Auth) verifyCookieValue(value string) (string, bool) {
decoded, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
return "", false
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 || parts[0] == "" {
return "", false
}
expected := a.cookieValue(parts[0])
return parts[0], hmac.Equal([]byte(expected), []byte(value))
}

View File

@ -4,7 +4,11 @@ import (
"html/template" "html/template"
"io/fs" "io/fs"
"log" "log"
"net"
"net/http" "net/http"
"strconv"
"strings"
"time"
"infinite-noodle/internal/assets" "infinite-noodle/internal/assets"
"infinite-noodle/internal/noodle" "infinite-noodle/internal/noodle"
@ -14,43 +18,462 @@ func StaticFiles() fs.FS {
return assets.FS return assets.FS
} }
func HandleMain(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) { type indexPageData struct {
Username string
Role string
ClientIP string
Noodles []noodle.Noodle
}
type userConfigPageData struct {
Username string
Role string
Users []noodle.User
Error string
}
func HandleMain(db *noodle.Database, pc *chan noodle.Noodle, auth *Auth) func(w http.ResponseWriter, req *http.Request) {
tmpl, err := template.ParseFS(assets.FS, "templates/*.html") tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
if err != nil { if err != nil {
log.Fatalf("Error parsing templates: %v", err) log.Fatalf("Error parsing templates: %v", err)
} }
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
data := db.GetAll() currentUser, _ := auth.CurrentUser(req)
data := indexPageData{
Username: currentUser.Username,
Role: currentUser.Role,
ClientIP: clientIPFromRequest(req),
Noodles: db.GetAll(),
}
if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil { if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
} }
func HandleDelete(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) { func HandleUsers(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
return func(w http.ResponseWriter, req *http.Request) { tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
q := req.URL.Query() if err != nil {
vals, ok := q["id"] log.Fatalf("Error parsing templates: %v", err)
if ok {
id := vals[0]
item := db.Get(id)
item.IsUp = false
*pc <- item
log.Printf("Deleting noodle=%v", item)
db.Delete(item.Id)
} }
http.Redirect(w, req, "/", http.StatusTemporaryRedirect) return func(w http.ResponseWriter, req *http.Request) {
currentUser, _ := auth.CurrentUser(req)
data := userConfigPageData{
Username: currentUser.Username,
Role: currentUser.Role,
}
if currentUser.Role == noodle.UserRoleAdmin {
data.Users = db.GetAllUsers()
}
if err := tmpl.ExecuteTemplate(w, "users.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} }
} }
func HandleAdd(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) { func HandleAddUser(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
if err != nil {
log.Fatalf("Error parsing templates: %v", err)
}
return func(w http.ResponseWriter, req *http.Request) {
currentUser, _ := auth.CurrentUser(req)
if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
if err := req.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := strings.TrimSpace(req.FormValue("username"))
password := req.FormValue("password")
role := normalizeUserRole(req.FormValue("role"))
if username == "" || password == "" {
renderUsersPage(w, tmpl, db, currentUser, "username and password are required", http.StatusBadRequest)
return
}
if role == "" {
renderUsersPage(w, tmpl, db, currentUser, "invalid user role", http.StatusBadRequest)
return
}
user := noodle.User{
Id: db.MakeID(),
Username: username,
Role: role,
PasswordHash: HashPassword(password),
CreatedAt: time.Now().UTC(),
}
if err := db.AddUser(user); err != nil {
renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, req, "/users", http.StatusSeeOther)
}
}
func HandleSetUserRole(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
if err != nil {
log.Fatalf("Error parsing templates: %v", err)
}
return func(w http.ResponseWriter, req *http.Request) {
currentUser, _ := auth.CurrentUser(req)
if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
if err := req.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := strings.TrimSpace(req.FormValue("username"))
role := normalizeUserRole(req.FormValue("role"))
if username == "" {
renderUsersPage(w, tmpl, db, currentUser, "username is required", http.StatusBadRequest)
return
}
if role == "" {
renderUsersPage(w, tmpl, db, currentUser, "invalid user role", http.StatusBadRequest)
return
}
if _, err := db.SetUserRole(username, role); err != nil {
renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, req, "/users", http.StatusSeeOther)
}
}
func HandleChangePassword(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
if err != nil {
log.Fatalf("Error parsing templates: %v", err)
}
return func(w http.ResponseWriter, req *http.Request) {
currentUser, _ := auth.CurrentUser(req)
if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
if err := req.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
targetUsername := strings.TrimSpace(req.FormValue("username"))
password := req.FormValue("password")
confirmPassword := req.FormValue("confirm_password")
if targetUsername == "" || password == "" {
renderUsersPage(w, tmpl, db, currentUser, "username and password are required", http.StatusBadRequest)
return
}
if confirmPassword != "" && password != confirmPassword {
renderUsersPage(w, tmpl, db, currentUser, "password confirmation does not match", http.StatusBadRequest)
return
}
if currentUser.Role != noodle.UserRoleAdmin && targetUsername != currentUser.Username {
http.Error(w, "cannot change another user's password", http.StatusForbidden)
return
}
if _, err := db.SetUserPassword(targetUsername, HashPassword(password)); err != nil {
renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, req, "/users", http.StatusSeeOther)
}
}
func HandleDeleteUser(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
if err != nil {
log.Fatalf("Error parsing templates: %v", err)
}
return func(w http.ResponseWriter, req *http.Request) {
currentUser, _ := auth.CurrentUser(req)
if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
if currentUser.Role != noodle.UserRoleAdmin {
http.Error(w, "admin access required", http.StatusForbidden)
return
}
if err := req.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := strings.TrimSpace(req.FormValue("username"))
if username == "" {
renderUsersPage(w, tmpl, db, currentUser, "username is required", http.StatusBadRequest)
return
}
if _, err := db.DeleteUser(username); err != nil {
renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest)
return
}
if username == currentUser.Username {
auth.ClearSession(w)
http.Redirect(w, req, "/login", http.StatusSeeOther)
return
}
http.Redirect(w, req, "/users", http.StatusSeeOther)
}
}
func HandleDelete(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) {
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed) http.Error(w, "", http.StatusMethodNotAllowed)
return return
} }
if err := req.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
id := strings.TrimSpace(req.FormValue("id"))
if id == "" {
http.Error(w, "missing noodle id", http.StatusBadRequest)
return
}
item, err := db.DeleteByID(id)
if err != nil {
http.Error(w, "noodle not found", http.StatusNotFound)
return
}
item.IsUp = false
*pc <- item
log.Printf("Deleting noodle=%v", item)
http.Redirect(w, req, "/", http.StatusTemporaryRedirect) http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
} }
} }
func HandleAdd(db *noodle.Database, pc *chan noodle.Noodle, auth *Auth) func(w http.ResponseWriter, req *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
currentUser, _ := auth.CurrentUser(req)
if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
if err := req.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
listenPort, err := strconv.Atoi(strings.TrimSpace(req.FormValue("listen_port")))
if err != nil {
http.Error(w, "invalid listen port", http.StatusBadRequest)
return
}
if !isValidPort(listenPort) {
http.Error(w, "listen port must be between 1 and 65535", http.StatusBadRequest)
return
}
destPort, err := strconv.Atoi(strings.TrimSpace(req.FormValue("dest_port")))
if err != nil {
http.Error(w, "invalid destination port", http.StatusBadRequest)
return
}
if !isValidPort(destPort) {
http.Error(w, "destination port must be between 1 and 65535", http.StatusBadRequest)
return
}
expiration, err := time.ParseDuration(strings.TrimSpace(req.FormValue("expiration")))
if err != nil {
http.Error(w, "invalid expiration duration", http.StatusBadRequest)
return
}
proto := strings.ToUpper(strings.TrimSpace(req.FormValue("proto")))
if proto != "TCP" && proto != "UDP" {
http.Error(w, "invalid protocol", http.StatusBadRequest)
return
}
src, err := normalizeSourceList(req.FormValue("src"))
if err != nil {
http.Error(w, "invalid source restriction", http.StatusBadRequest)
return
}
destHost := strings.TrimSpace(req.FormValue("dest_host"))
if destHost == "" {
http.Error(w, "destination host is required", http.StatusBadRequest)
return
}
item := noodle.Noodle{
Id: db.MakeID(),
Name: strings.TrimSpace(req.FormValue("name")),
Proto: proto,
Src: src,
ListenPort: listenPort,
DestPort: destPort,
DestHost: destHost,
Expiration: expiration,
IsUp: true,
CreatedBy: currentUser.Username,
}
if err := db.Add(item); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
*pc <- item
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
}
}
func HandleToggle(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
if err := req.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
id := strings.TrimSpace(req.FormValue("id"))
if id == "" {
http.Error(w, "missing noodle id", http.StatusBadRequest)
return
}
item, err := db.SetIsUp(id, req.FormValue("is_up") == "on")
if err != nil {
http.Error(w, "noodle not found", http.StatusNotFound)
return
}
*pc <- item
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
}
}
func clientIPFromRequest(req *http.Request) string {
forwardedFor := strings.TrimSpace(req.Header.Get("X-Forwarded-For"))
if forwardedFor != "" {
parts := strings.Split(forwardedFor, ",")
ip := strings.TrimSpace(parts[0])
if parsed := net.ParseIP(ip); parsed != nil {
return parsed.String()
}
}
realIP := strings.TrimSpace(req.Header.Get("X-Real-Ip"))
if realIP != "" {
if parsed := net.ParseIP(realIP); parsed != nil {
return parsed.String()
}
}
host, _, err := net.SplitHostPort(strings.TrimSpace(req.RemoteAddr))
if err == nil {
if parsed := net.ParseIP(host); parsed != nil {
return parsed.String()
}
}
if parsed := net.ParseIP(strings.TrimSpace(req.RemoteAddr)); parsed != nil {
return parsed.String()
}
return "127.0.0.1"
}
func isValidPort(port int) bool {
return port >= 1 && port <= 65535
}
func normalizeSourceList(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
if strings.EqualFold(raw, "All") {
return "All", nil
}
parts := strings.Split(raw, ",")
normalized := make([]string, 0, len(parts))
for _, part := range parts {
entry := strings.TrimSpace(part)
if entry == "" {
return "", net.InvalidAddrError("empty source")
}
if strings.Contains(entry, "/") {
_, network, err := net.ParseCIDR(entry)
if err != nil {
return "", err
}
normalized = append(normalized, network.String())
continue
}
parsed := net.ParseIP(entry)
if parsed == nil {
return "", net.InvalidAddrError(entry)
}
normalized = append(normalized, parsed.String())
}
return strings.Join(normalized, ", "), nil
}
func normalizeUserRole(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case noodle.UserRoleAdmin:
return noodle.UserRoleAdmin
case noodle.UserRoleRegular:
return noodle.UserRoleRegular
default:
return ""
}
}
func renderUsersPage(w http.ResponseWriter, tmpl *template.Template, db *noodle.Database, currentUser authUser, message string, status int) {
w.WriteHeader(status)
data := userConfigPageData{
Username: currentUser.Username,
Role: currentUser.Role,
Error: message,
}
if currentUser.Role == noodle.UserRoleAdmin {
data.Users = db.GetAllUsers()
}
if err := tmpl.ExecuteTemplate(w, "users.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}