Add auth-managed proxy UI improvements

This commit is contained in:
2026-04-03 20:58:54 -04:00
parent 7ed709ad3d
commit f2a246ce6b
9 changed files with 1384 additions and 82 deletions

View File

@ -28,19 +28,34 @@ func Run(cfg Config) error {
db := noodle.NewDatabase(cfg.DataPath)
defer db.Close()
auth, err := web.NewAuth(db)
if err != nil {
return err
}
if err := auth.EnsureDefaultUser(); err != nil {
return err
}
if cfg.RunTest {
runTestSequence(db)
}
go systemCheck(db, noodleChannel)
go expirationCheck(db, noodleChannel)
go proxify(noodleChannel)
go proxify(db, noodleChannel)
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(web.StaticFiles()))))
http.HandleFunc("/", web.HandleMain(db, &noodleChannel))
http.HandleFunc("/add", web.HandleAdd(db, &noodleChannel))
http.HandleFunc("/toggle", web.HandleToggle(db, &noodleChannel))
http.HandleFunc("/delete", web.HandleDelete(db, &noodleChannel))
http.HandleFunc("/login", auth.HandleLogin())
http.HandleFunc("/logout", auth.HandleLogout())
http.HandleFunc("/", auth.RequireAuth(web.HandleMain(db, &noodleChannel, auth)))
http.HandleFunc("/users", auth.RequireAuth(web.HandleUsers(auth, db)))
http.HandleFunc("/users/add", auth.RequireAdmin(web.HandleAddUser(auth, db)))
http.HandleFunc("/users/role", auth.RequireAdmin(web.HandleSetUserRole(auth, db)))
http.HandleFunc("/users/password", auth.RequireAuth(web.HandleChangePassword(auth, db)))
http.HandleFunc("/users/delete", auth.RequireAuth(web.HandleDeleteUser(auth, db)))
http.HandleFunc("/add", auth.RequireAuth(web.HandleAdd(db, &noodleChannel, auth)))
http.HandleFunc("/toggle", auth.RequireAuth(web.HandleToggle(db, &noodleChannel)))
http.HandleFunc("/delete", auth.RequireAuth(web.HandleDelete(db, &noodleChannel)))
log.Printf("Server starting on %s", listenAddr)
return http.ListenAndServe(listenAddr, nil)
@ -56,7 +71,7 @@ func systemCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) {
}
}
func proxify(noodleChannel chan noodle.Noodle) {
func proxify(db *noodle.Database, noodleChannel chan noodle.Noodle) {
noodleMap := make(map[string]managedProxy)
for {
item := <-noodleChannel
@ -65,6 +80,9 @@ func proxify(noodleChannel chan noodle.Noodle) {
proxy, err := newProxy(item)
if err != nil {
log.Print(err)
if _, updateErr := db.SetIsUp(item.Id, false); updateErr != nil {
log.Print(updateErr)
}
continue
}
noodleMap[item.Id] = proxy
@ -408,15 +426,73 @@ func (p *udpNoodleProxy) Close() error {
return conn.Close()
}
func allowSource(allowedIP string, addr net.Addr) bool {
if allowedIP == "" || allowedIP == "All" {
func allowSource(allowedIPs string, addr net.Addr) bool {
if allowedIPs == "" || strings.EqualFold(strings.TrimSpace(allowedIPs), "All") {
return true
}
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return false
}
return host == allowedIP
hostIP := normalizeIP(net.ParseIP(host))
if hostIP == nil {
return false
}
for _, item := range strings.Split(allowedIPs, ",") {
entry := strings.TrimSpace(item)
if strings.Contains(entry, "/") {
_, network, err := net.ParseCIDR(entry)
if err == nil && networkContainsIP(network, hostIP) {
return true
}
continue
}
entryIP := normalizeIP(net.ParseIP(entry))
if entryIP != nil && entryIP.Equal(hostIP) {
return true
}
}
return false
}
func normalizeIP(ip net.IP) net.IP {
if ip == nil {
return nil
}
if v4 := ip.To4(); v4 != nil {
return v4
}
return ip
}
func networkContainsIP(network *net.IPNet, ip net.IP) bool {
if network == nil || ip == nil {
return false
}
if network.Contains(ip) {
return true
}
networkIP := normalizeIP(network.IP)
normalizedIP := normalizeIP(ip)
if networkIP == nil || normalizedIP == nil {
return false
}
if len(networkIP) != len(normalizedIP) {
return false
}
mask := network.Mask
if len(mask) != len(normalizedIP) {
return false
}
for i := range normalizedIP {
if normalizedIP[i]&mask[i] != networkIP[i]&mask[i] {
return false
}
}
return true
}
func closeWrite(conn net.Conn) {
@ -440,42 +516,9 @@ func expirationCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) {
defer ticker.Stop()
for range ticker.C {
noodles := db.GetAll()
for _, item := range noodles {
if !item.IsUp {
continue
}
if item.Expiration <= 0 {
if item.IsUp {
item.IsUp = false
noodleChannel <- item
}
if err := db.Delete(item.Id); err != nil {
log.Print(err)
}
continue
}
item.Expiration -= time.Second
if item.Expiration <= 0 {
item.Expiration = 0
if err := db.Update(item); err != nil {
log.Print(err)
continue
}
if item.IsUp {
item.IsUp = false
noodleChannel <- item
}
if err := db.Delete(item.Id); err != nil {
log.Print(err)
}
continue
}
if err := db.Update(item); err != nil {
log.Print(err)
}
stopped := db.TickExpirations()
for _, item := range stopped {
noodleChannel <- item
}
}
}

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>
@ -49,6 +70,7 @@
<th>Dest Host/IP</th>
<th>Expiration</th>
<th>Status</th>
<th>Created By</th>
<th></th>
</tr>
</thead>
@ -64,11 +86,15 @@
</div>
</td>
<td>
<input class="input is-link is-small" type="text" form="add-noodle" name="src" list="allow-from-options" placeholder="All or source IP" value="All" autocomplete="off" required/>
<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>
@ -76,6 +102,8 @@
<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>
<form id="add-noodle" method="post" action="/add" autocomplete="off">
<button class="button" type="submit">
@ -101,10 +129,14 @@
</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}}
@ -114,11 +146,20 @@
<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";

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,9 @@ 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
@ -70,6 +92,9 @@ func (db *Database) Add(item Noodle) error {
}
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
@ -78,6 +103,9 @@ func (db *Database) Update(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
@ -88,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

@ -2,6 +2,11 @@ package noodle
import "time"
const (
UserRoleRegular = "regular"
UserRoleAdmin = "admin"
)
type Noodle struct {
Id string
Name string
@ -12,4 +17,13 @@ type Noodle struct {
DestHost string
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

@ -19,17 +19,29 @@ func StaticFiles() fs.FS {
}
type indexPageData struct {
Username string
Role string
ClientIP string
Noodles []noodle.Noodle
}
func HandleMain(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) {
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) {
currentUser, _ := auth.CurrentUser(req)
data := indexPageData{
Username: currentUser.Username,
Role: currentUser.Role,
ClientIP: clientIPFromRequest(req),
Noodles: db.GetAll(),
}
@ -39,25 +51,231 @@ func HandleMain(db *noodle.Database, pc *chan noodle.Noodle) func(w http.Respons
}
}
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)
}
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 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) {
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)
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) func(w http.ResponseWriter, req *http.Request) {
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
@ -73,12 +291,20 @@ func HandleAdd(db *noodle.Database, pc *chan noodle.Noodle) func(w http.Response
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 {
@ -92,16 +318,18 @@ func HandleAdd(db *noodle.Database, pc *chan noodle.Noodle) func(w http.Response
return
}
clientIP := clientIPFromRequest(req)
src := strings.TrimSpace(req.FormValue("src"))
if src == clientIP {
src = clientIP
}
if src != "All" && net.ParseIP(src) == nil {
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")),
@ -109,9 +337,10 @@ func HandleAdd(db *noodle.Database, pc *chan noodle.Noodle) func(w http.Response
Src: src,
ListenPort: listenPort,
DestPort: destPort,
DestHost: strings.TrimSpace(req.FormValue("dest_host")),
DestHost: destHost,
Expiration: expiration,
IsUp: true,
CreatedBy: currentUser.Username,
}
if err := db.Add(item); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -141,17 +370,11 @@ func HandleToggle(db *noodle.Database, pc *chan noodle.Noodle) func(w http.Respo
return
}
item := db.Get(id)
if item.Id == "" {
item, err := db.SetIsUp(id, req.FormValue("is_up") == "on")
if err != nil {
http.Error(w, "noodle not found", http.StatusNotFound)
return
}
item.IsUp = req.FormValue("is_up") == "on"
if err := db.Update(item); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
*pc <- item
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
@ -188,3 +411,69 @@ func clientIPFromRequest(req *http.Request) 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)
}
}