Add auth-managed proxy UI improvements
This commit is contained in:
39
README.md
39
README.md
@ -3,6 +3,7 @@
|
||||
`infinite-noodle` is a small Go service that combines:
|
||||
|
||||
- a web UI for viewing configured TCP proxies
|
||||
- a login-protected UI with user and admin account management
|
||||
- a Bitcask-backed data store for persisted proxy definitions
|
||||
- a TCP proxy runner built with Go's standard `net` package
|
||||
|
||||
@ -13,12 +14,13 @@ This README is written for working in a GitHub Codespace.
|
||||
Each "noodle" is a TCP forwarding rule with:
|
||||
|
||||
- a name
|
||||
- an allowed source (`All` or a specific source IP)
|
||||
- an allowed source list (`All`, one or more IPs, or CIDR ranges)
|
||||
- a listen port
|
||||
- a destination host
|
||||
- a destination port
|
||||
- an expiration duration
|
||||
- an up/down state
|
||||
- the user who created it
|
||||
|
||||
When the app starts, it loads saved noodles from the local database and starts any active proxy routes. It also serves a web UI for creating, pausing, resuming, and deleting them.
|
||||
|
||||
@ -27,14 +29,19 @@ When the app starts, it loads saved noodles from the local database and starts a
|
||||
The project is functional enough to:
|
||||
|
||||
- start the web app
|
||||
- create a default admin user on a new database
|
||||
- require login before the proxy management UI is available
|
||||
- support two user levels: `regular` and `admin`
|
||||
- let any signed-in user change their own password
|
||||
- let admin users create users, change roles, change any user's password, and delete user accounts
|
||||
- load stored proxy definitions from `./infinite.db`
|
||||
- create new noodles from the UI
|
||||
- run active TCP and UDP proxies
|
||||
- restrict a proxy to a specific source IP
|
||||
- restrict a proxy to comma-separated source IPs and CIDR ranges
|
||||
- update expiration values in the database every second while a proxy is active
|
||||
- close and delete expired noodles automatically
|
||||
- pause a noodle from the UI without decrementing its expiration
|
||||
- delete existing noodles from the UI
|
||||
- delete existing noodles from the UI with confirmation
|
||||
|
||||
Current limitations:
|
||||
|
||||
@ -59,6 +66,14 @@ Default runtime settings:
|
||||
|
||||
Open the app in the browser from the forwarded port for `7878`.
|
||||
|
||||
On a brand-new database, the app creates a default UI user:
|
||||
|
||||
- username: `admin`
|
||||
- password: `admin`
|
||||
- role: `admin`
|
||||
|
||||
Any signed-in user can access `/users` to manage their own password. Admin users also get full user management on that page.
|
||||
|
||||
You can also override the defaults:
|
||||
|
||||
```bash
|
||||
@ -74,10 +89,10 @@ Available flags:
|
||||
|
||||
## UI Behavior
|
||||
|
||||
The main table includes an add row for creating a proxy with:
|
||||
The web UI is protected by a login page. After signing in, the main table includes an add row for creating a proxy with:
|
||||
|
||||
- `Name`
|
||||
- `Allow From`: accepts `All` or a specific IP address, with the current client IP suggested
|
||||
- `Allow From`: accepts `All`, comma-separated IP addresses, or CIDR ranges such as `10.0.0.5, 192.168.1.0/24`
|
||||
- `Proto`: choose `TCP` or `UDP`
|
||||
- `Listen Port`
|
||||
- `Destination Port`
|
||||
@ -89,6 +104,17 @@ The `Status` column is a checkbox:
|
||||
- checked: the proxy is active and the expiration counts down
|
||||
- unchecked: the proxy is closed and the expiration is paused
|
||||
|
||||
The table also shows:
|
||||
|
||||
- `Created By`: the authenticated user who created the proxy
|
||||
|
||||
Delete actions in the UI prompt for confirmation before the request is submitted.
|
||||
|
||||
The `/users` page behavior depends on role:
|
||||
|
||||
- `regular`: can change their own password
|
||||
- `admin`: can create users, change roles, change any password, and delete users
|
||||
|
||||
The expiration value is shown as a live countdown in the browser. When it reaches zero, the row is removed from the UI and the noodle is deleted from the database.
|
||||
|
||||
## Codespaces Port Notes
|
||||
@ -143,8 +169,11 @@ GOOS=linux GOARCH=amd64 go build -buildvcs=false -o target/infinite-noodle.net-p
|
||||
- [`cmd/infinite-noodle/main.go`](/data/project/go/src/infinite-noodle/cmd/infinite-noodle/main.go): binary entrypoint and CLI flags
|
||||
- [`internal/app/app.go`](/data/project/go/src/infinite-noodle/internal/app/app.go): app startup, HTTP server, and proxy lifecycle
|
||||
- [`internal/noodle/database.go`](/data/project/go/src/infinite-noodle/internal/noodle/database.go): Bitcask storage layer
|
||||
- [`internal/web/auth.go`](/data/project/go/src/infinite-noodle/internal/web/auth.go): login, sessions, and role-based access control
|
||||
- [`internal/web/handlers.go`](/data/project/go/src/infinite-noodle/internal/web/handlers.go): HTML handlers
|
||||
- [`internal/assets/templates/index.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/index.html): UI template
|
||||
- [`internal/assets/templates/login.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/login.html): login page
|
||||
- [`internal/assets/templates/users.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/users.html): user configuration page
|
||||
|
||||
## Verification Note
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> - © 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";
|
||||
|
||||
101
internal/assets/templates/login.html
Normal file
101
internal/assets/templates/login.html
Normal 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>
|
||||
234
internal/assets/templates/users.html
Normal file
234
internal/assets/templates/users.html
Normal 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>
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
278
internal/web/auth.go
Normal 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))
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user