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:
- 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 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.
@ -12,27 +13,40 @@ This README is written for working in a GitHub Codespace.
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 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 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
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`
- run active TCP proxies
- delete existing noodles from the UI
- create new 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 for creating noodles
- proxy routing is TCP only in the current code path
- there is no REST API; management is currently through the server-rendered UI
- protocols are limited to TCP and UDP
## Running In A Codespace
@ -52,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
@ -65,6 +87,36 @@ Available flags:
- `-data`: path to the Bitcask database directory
- `-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
In Codespaces, you will usually need to forward:
@ -85,11 +137,9 @@ Typical flow:
1. Run the app with `go run ./cmd/infinite-noodle`
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`
Because create is not implemented in the UI yet, you currently need to seed or insert noodle records another way.
## Building Binaries
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/`.
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
- [`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

3
go.mod
View File

@ -3,14 +3,13 @@ module infinite-noodle
go 1.19
require (
github.com/google/uuid v1.6.0
go.mills.io/bitcask/v2 v2.1.3
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9
)
require (
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 // 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/golang-lru/v2 v2.0.7 // 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/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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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 (
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"infinite-noodle/internal/noodle"
"infinite-noodle/internal/web"
"inet.af/tcpproxy"
)
type Config struct {
@ -26,16 +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 tcpProxify(noodleChannel)
go expirationCheck(db, 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("/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)
@ -51,23 +71,22 @@ func systemCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) {
}
}
func startProxy(proxy *tcpproxy.Proxy) {
log.Print(proxy.Run())
}
func tcpProxify(noodleChannel chan noodle.Noodle) {
noodleMap := make(map[string]*tcpproxy.Proxy)
func proxify(db *noodle.Database, noodleChannel chan noodle.Noodle) {
noodleMap := make(map[string]managedProxy)
for {
item := <-noodleChannel
_, running := noodleMap[item.Id]
if item.IsUp && !running {
var proxy tcpproxy.Proxy
src := fmt.Sprintf("0.0.0.0:%d", item.ListenPort)
dst := fmt.Sprintf("%s:%d", item.DestHost, item.DestPort)
log.Printf("Starting a noodle from %s to %s", src, dst)
proxy.AddRoute(src, tcpproxy.To(dst))
noodleMap[item.Id] = &proxy
go startProxy(&proxy)
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
go proxy.Run()
continue
}
@ -76,6 +95,430 @@ func tcpProxify(noodleChannel chan noodle.Noodle) {
if err := noodleMap[item.Id].Close(); err != nil {
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{
Id: db.MakeID(),
Name: "Name_Test",
Proto: "Proto_Test",
Proto: "TCP",
Src: "All",
ListenPort: 1080 + i,
DestPort: 22,
DestHost: "localhost",
Expiration: time.Now().Second(),
Expiration: time.Duration(time.Now().Second()) * time.Second,
IsUp: true,
}
log.Printf("Test noodle=%v", item)

View File

@ -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;
}
</style>
</head>
@ -34,6 +46,15 @@
</div>
<div class="banner-container">
<!-- <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>
</nav>
@ -43,49 +64,79 @@
<tr>
<th>Name</th>
<th>Proto</th>
<th>Allow From</th>
<th>Listening Port</th>
<th>Dest Port</th>
<th>Dest Host/IP</th>
<th>Expiration</th>
<th>Status</th>
<th>Created By</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td><input class="input is-link is-small" type="text" placeholder="Name"/></td>
<td>TCP</td>
<td><input class="input is-link is-small" type="text" placeholder="Listen Port"/></td>
<td><input class="input is-link is-small" type="text" placeholder="Destination Port"/></td>
<td><input class="input is-link is-small" type="text" placeholder="Destination Host"/></td>
<td>Expiration</td>
<td><input class="input is-link is-small" type="text" form="add-noodle" name="name" placeholder="Name" autocomplete="off" required/></td>
<td>
<div class="select is-small is-link">
<select form="add-noodle" name="proto" autocomplete="off" required>
<option value="TCP">TCP</option>
<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>
<button class="button">
<span class="icon has-text-success"><i class="fas fa-plus"></i></span>
</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>
</button>
</form>
</td>
</tr>
{{range .}}
{{range .Noodles}}
<tr>
<td>{{.Name}}</td>
<td>{{.Proto}}</td>
<td>{{.Src}}</td>
<td>{{.ListenPort}}</td>
<td>{{.DestPort}}</td>
<td>{{.DestHost}}</td>
<td>{{.Expiration}}</td>
<td data-expiration-ms="{{.Expiration.Milliseconds}}" data-is-up="{{.IsUp}}">{{.Expiration}}</td>
<td>
{{if .IsUp}}
<span class="icon has-text-success"><i class="fas fa-check-square"></i></span>
{{ else }}
<span class="icon has-text-danger"><i class="fas fa-ban"></i></span>
{{ end }}
<form method="post" action="/toggle" autocomplete="off">
<input type="hidden" name="id" value="{{.Id}}"/>
<label>
<input type="checkbox" name="is_up" onchange="this.form.submit()" {{if .IsUp}}checked{{end}}/>
</label>
</form>
</td>
<td>{{.CreatedBy}}</td>
<td>
<a href="/delete?id={{.Id}}">
<span class="icon has-text-danger"><i class="fas fa-minus"></i></span>
</a>
<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>
</button>
</form>
</td>
</tr>
{{end}}
@ -95,10 +146,72 @@
<footer class="footer">
<div class="content has-text-centered">
<p>
<strong>Infinite-Noodles Network Proxy</strong> - &copy; 2025 Jimmy Allen
<strong>Infinite-Noodles Network Proxy</strong>
</p>
</div>
</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>
</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
import (
"errors"
"fmt"
"log"
"sync"
"time"
"github.com/google/uuid"
"go.mills.io/bitcask/v2"
)
var ErrUserNotFound = errors.New("user not found")
var ErrLastAdminRemoval = errors.New("cannot remove the last admin")
type Database struct {
connection *bitcask.Bitcask
Handle *bitcask.Collection
Users *bitcask.Collection
mu sync.Mutex
}
func NewDatabase(path string) *Database {
@ -20,6 +29,7 @@ func NewDatabase(path string) *Database {
return &Database{
connection: db,
Handle: db.Collection("noodles"),
Users: db.Collection("users"),
}
}
@ -33,6 +43,9 @@ func (db *Database) MakeID() string {
}
func (db *Database) GetAll() []Noodle {
db.mu.Lock()
defer db.mu.Unlock()
var data []Noodle
if err := db.Handle.List(&data); err != nil {
log.Print(err)
@ -42,6 +55,9 @@ func (db *Database) GetAll() []Noodle {
}
func (db *Database) GetAllGeneric() []interface{} {
db.mu.Lock()
defer db.mu.Unlock()
var data []interface{}
if err := db.Handle.List(&data); err != nil {
log.Print(err)
@ -51,6 +67,9 @@ func (db *Database) GetAllGeneric() []interface{} {
}
func (db *Database) Get(id string) Noodle {
db.mu.Lock()
defer db.mu.Unlock()
var item Noodle
log.Printf("Looking up noodle key='%s'", 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 {
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 {
log.Print(err)
return err
@ -70,6 +103,9 @@ func (db *Database) Add(item Noodle) error {
}
func (db *Database) Delete(id string) error {
db.mu.Lock()
defer db.mu.Unlock()
if err := db.Handle.Delete(id); err != nil {
log.Print(err)
return err
@ -80,3 +116,248 @@ func (db *Database) Delete(id string) error {
func (db *Database) Close() error {
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
import "time"
const (
UserRoleRegular = "regular"
UserRoleAdmin = "admin"
)
type Noodle struct {
Id string
Name string
Proto string
Src string
ListenPort int
DestPort int
DestHost string
Expiration int
Expiration time.Duration
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"
"io/fs"
"log"
"net"
"net/http"
"strconv"
"strings"
"time"
"infinite-noodle/internal/assets"
"infinite-noodle/internal/noodle"
@ -14,43 +18,462 @@ func StaticFiles() fs.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")
if err != nil {
log.Fatalf("Error parsing templates: %v", err)
}
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func HandleDelete(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query()
vals, ok := q["id"]
if ok {
id := vals[0]
item := db.Get(id)
item.IsUp = false
*pc <- item
log.Printf("Deleting noodle=%v", item)
db.Delete(item.Id)
}
func HandleUsers(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)
}
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) {
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.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)
}
}
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)
}
}