Compare commits
4 Commits
ded36aa0e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f2a246ce6b | |||
| 7ed709ad3d | |||
| 9f90997bab | |||
| 0f3803b0e6 |
81
README.md
81
README.md
@ -3,8 +3,9 @@
|
|||||||
`infinite-noodle` is a small Go service that combines:
|
`infinite-noodle` is a small Go service that combines:
|
||||||
|
|
||||||
- a web UI for viewing configured TCP proxies
|
- a web UI for viewing configured TCP proxies
|
||||||
|
- a login-protected UI with user and admin account management
|
||||||
- a Bitcask-backed data store for persisted proxy definitions
|
- a Bitcask-backed data store for persisted proxy definitions
|
||||||
- a TCP proxy runner powered by `inet.af/tcpproxy`
|
- a TCP proxy runner built with Go's standard `net` package
|
||||||
|
|
||||||
This README is written for working in a GitHub Codespace.
|
This README is written for working in a GitHub Codespace.
|
||||||
|
|
||||||
@ -12,27 +13,40 @@ This README is written for working in a GitHub Codespace.
|
|||||||
|
|
||||||
Each "noodle" is a TCP forwarding rule with:
|
Each "noodle" is a TCP forwarding rule with:
|
||||||
|
|
||||||
|
- a name
|
||||||
|
- an allowed source list (`All`, one or more IPs, or CIDR ranges)
|
||||||
- a listen port
|
- a listen port
|
||||||
- a destination host
|
- a destination host
|
||||||
- a destination port
|
- a destination port
|
||||||
|
- an expiration duration
|
||||||
- an up/down state
|
- an up/down state
|
||||||
|
- the user who created it
|
||||||
|
|
||||||
When the app starts, it loads saved noodles from the local database and starts any active proxy routes. It also serves a basic web UI for listing and deleting them.
|
When the app starts, it loads saved noodles from the local database and starts any active proxy routes. It also serves a web UI for creating, pausing, resuming, and deleting them.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
The project is functional enough to:
|
The project is functional enough to:
|
||||||
|
|
||||||
- start the web app
|
- start the web app
|
||||||
|
- create a default admin user on a new database
|
||||||
|
- require login before the proxy management UI is available
|
||||||
|
- support two user levels: `regular` and `admin`
|
||||||
|
- let any signed-in user change their own password
|
||||||
|
- let admin users create users, change roles, change any user's password, and delete user accounts
|
||||||
- load stored proxy definitions from `./infinite.db`
|
- load stored proxy definitions from `./infinite.db`
|
||||||
- run active TCP proxies
|
- create new noodles from the UI
|
||||||
- delete existing noodles from the UI
|
- run active TCP and UDP proxies
|
||||||
|
- restrict a proxy to comma-separated source IPs and CIDR ranges
|
||||||
|
- update expiration values in the database every second while a proxy is active
|
||||||
|
- close and delete expired noodles automatically
|
||||||
|
- pause a noodle from the UI without decrementing its expiration
|
||||||
|
- delete existing noodles from the UI with confirmation
|
||||||
|
|
||||||
What is not wired up yet:
|
Current limitations:
|
||||||
|
|
||||||
- the add row in the UI is present, but create/save is not implemented
|
- there is no REST API; management is currently through the server-rendered UI
|
||||||
- there is no REST API for creating noodles
|
- protocols are limited to TCP and UDP
|
||||||
- proxy routing is TCP only in the current code path
|
|
||||||
|
|
||||||
## Running In A Codespace
|
## Running In A Codespace
|
||||||
|
|
||||||
@ -52,6 +66,14 @@ Default runtime settings:
|
|||||||
|
|
||||||
Open the app in the browser from the forwarded port for `7878`.
|
Open the app in the browser from the forwarded port for `7878`.
|
||||||
|
|
||||||
|
On a brand-new database, the app creates a default UI user:
|
||||||
|
|
||||||
|
- username: `admin`
|
||||||
|
- password: `admin`
|
||||||
|
- role: `admin`
|
||||||
|
|
||||||
|
Any signed-in user can access `/users` to manage their own password. Admin users also get full user management on that page.
|
||||||
|
|
||||||
You can also override the defaults:
|
You can also override the defaults:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -65,6 +87,36 @@ Available flags:
|
|||||||
- `-data`: path to the Bitcask database directory
|
- `-data`: path to the Bitcask database directory
|
||||||
- `-test`: seeds the database with sample noodles on startup
|
- `-test`: seeds the database with sample noodles on startup
|
||||||
|
|
||||||
|
## UI Behavior
|
||||||
|
|
||||||
|
The web UI is protected by a login page. After signing in, the main table includes an add row for creating a proxy with:
|
||||||
|
|
||||||
|
- `Name`
|
||||||
|
- `Allow From`: accepts `All`, comma-separated IP addresses, or CIDR ranges such as `10.0.0.5, 192.168.1.0/24`
|
||||||
|
- `Proto`: choose `TCP` or `UDP`
|
||||||
|
- `Listen Port`
|
||||||
|
- `Destination Port`
|
||||||
|
- `Destination Host`
|
||||||
|
- `Expiration`: parsed as a Go `time.Duration` such as `30s`, `5m`, or `1h15m`
|
||||||
|
|
||||||
|
The `Status` column is a checkbox:
|
||||||
|
|
||||||
|
- checked: the proxy is active and the expiration counts down
|
||||||
|
- unchecked: the proxy is closed and the expiration is paused
|
||||||
|
|
||||||
|
The table also shows:
|
||||||
|
|
||||||
|
- `Created By`: the authenticated user who created the proxy
|
||||||
|
|
||||||
|
Delete actions in the UI prompt for confirmation before the request is submitted.
|
||||||
|
|
||||||
|
The `/users` page behavior depends on role:
|
||||||
|
|
||||||
|
- `regular`: can change their own password
|
||||||
|
- `admin`: can create users, change roles, change any password, and delete users
|
||||||
|
|
||||||
|
The expiration value is shown as a live countdown in the browser. When it reaches zero, the row is removed from the UI and the noodle is deleted from the database.
|
||||||
|
|
||||||
## Codespaces Port Notes
|
## Codespaces Port Notes
|
||||||
|
|
||||||
In Codespaces, you will usually need to forward:
|
In Codespaces, you will usually need to forward:
|
||||||
@ -85,11 +137,9 @@ Typical flow:
|
|||||||
|
|
||||||
1. Run the app with `go run ./cmd/infinite-noodle`
|
1. Run the app with `go run ./cmd/infinite-noodle`
|
||||||
2. Start the test echo server on `127.0.0.1:6666`
|
2. Start the test echo server on `127.0.0.1:6666`
|
||||||
3. Ensure the database contains a noodle that listens on `5555` and forwards to `127.0.0.1:6666`
|
3. Add a noodle in the UI that listens on `5555` and forwards to `127.0.0.1:6666`
|
||||||
4. Run the test client, which connects to `127.0.0.1:5555`
|
4. Run the test client, which connects to `127.0.0.1:5555`
|
||||||
|
|
||||||
Because create is not implemented in the UI yet, you currently need to seed or insert noodle records another way.
|
|
||||||
|
|
||||||
## Building Binaries
|
## Building Binaries
|
||||||
|
|
||||||
The repo includes [`build.sh`](/data/project/go/src/infinite-noodle/build.sh), which builds binaries for:
|
The repo includes [`build.sh`](/data/project/go/src/infinite-noodle/build.sh), which builds binaries for:
|
||||||
@ -108,13 +158,22 @@ sh build.sh
|
|||||||
|
|
||||||
Artifacts are written under `./target/`.
|
Artifacts are written under `./target/`.
|
||||||
|
|
||||||
|
To build only the Linux amd64 target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOOS=linux GOARCH=amd64 go build -buildvcs=false -o target/infinite-noodle.net-proxy ./cmd/infinite-noodle
|
||||||
|
```
|
||||||
|
|
||||||
## Important Files
|
## Important Files
|
||||||
|
|
||||||
- [`cmd/infinite-noodle/main.go`](/data/project/go/src/infinite-noodle/cmd/infinite-noodle/main.go): binary entrypoint and CLI flags
|
- [`cmd/infinite-noodle/main.go`](/data/project/go/src/infinite-noodle/cmd/infinite-noodle/main.go): binary entrypoint and CLI flags
|
||||||
- [`internal/app/app.go`](/data/project/go/src/infinite-noodle/internal/app/app.go): app startup, HTTP server, and proxy lifecycle
|
- [`internal/app/app.go`](/data/project/go/src/infinite-noodle/internal/app/app.go): app startup, HTTP server, and proxy lifecycle
|
||||||
- [`internal/noodle/database.go`](/data/project/go/src/infinite-noodle/internal/noodle/database.go): Bitcask storage layer
|
- [`internal/noodle/database.go`](/data/project/go/src/infinite-noodle/internal/noodle/database.go): Bitcask storage layer
|
||||||
|
- [`internal/web/auth.go`](/data/project/go/src/infinite-noodle/internal/web/auth.go): login, sessions, and role-based access control
|
||||||
- [`internal/web/handlers.go`](/data/project/go/src/infinite-noodle/internal/web/handlers.go): HTML handlers
|
- [`internal/web/handlers.go`](/data/project/go/src/infinite-noodle/internal/web/handlers.go): HTML handlers
|
||||||
- [`internal/assets/templates/index.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/index.html): UI template
|
- [`internal/assets/templates/index.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/index.html): UI template
|
||||||
|
- [`internal/assets/templates/login.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/login.html): login page
|
||||||
|
- [`internal/assets/templates/users.html`](/data/project/go/src/infinite-noodle/internal/assets/templates/users.html): user configuration page
|
||||||
|
|
||||||
## Verification Note
|
## Verification Note
|
||||||
|
|
||||||
|
|||||||
3
go.mod
3
go.mod
@ -3,14 +3,13 @@ module infinite-noodle
|
|||||||
go 1.19
|
go 1.19
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
go.mills.io/bitcask/v2 v2.1.3
|
go.mills.io/bitcask/v2 v2.1.3
|
||||||
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 // indirect
|
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 // indirect
|
||||||
github.com/gofrs/flock v0.8.1 // indirect
|
github.com/gofrs/flock v0.8.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/hashicorp/go-immutable-radix/v2 v2.0.0 // indirect
|
github.com/hashicorp/go-immutable-radix/v2 v2.0.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/mattetti/filebuffer v1.0.1 // indirect
|
github.com/mattetti/filebuffer v1.0.1 // indirect
|
||||||
|
|||||||
3
go.sum
3
go.sum
@ -1,6 +1,5 @@
|
|||||||
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 h1:uHogIJ9bXH75ZYrXnVShHIyywFiUZ7OOabwd9Sfd8rw=
|
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 h1:uHogIJ9bXH75ZYrXnVShHIyywFiUZ7OOabwd9Sfd8rw=
|
||||||
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81/go.mod h1:6ZvnjTZX1LNo1oLpfaJK8h+MXqHxcBFBIwkgsv+xlv0=
|
github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81/go.mod h1:6ZvnjTZX1LNo1oLpfaJK8h+MXqHxcBFBIwkgsv+xlv0=
|
||||||
github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
@ -50,5 +49,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
|
|||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9 h1:zomTWJvjwLbKRgGameQtpK6DNFUbZ2oNJuWhgUkGp3M=
|
|
||||||
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9/go.mod h1:Tojt5kmHpDIR2jMojxzZK2w2ZR7OILODmUo2gaSwjrk=
|
|
||||||
|
|||||||
@ -2,14 +2,16 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"infinite-noodle/internal/noodle"
|
"infinite-noodle/internal/noodle"
|
||||||
"infinite-noodle/internal/web"
|
"infinite-noodle/internal/web"
|
||||||
|
|
||||||
"inet.af/tcpproxy"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -26,16 +28,34 @@ func Run(cfg Config) error {
|
|||||||
db := noodle.NewDatabase(cfg.DataPath)
|
db := noodle.NewDatabase(cfg.DataPath)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
|
auth, err := web.NewAuth(db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := auth.EnsureDefaultUser(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.RunTest {
|
if cfg.RunTest {
|
||||||
runTestSequence(db)
|
runTestSequence(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
go systemCheck(db, noodleChannel)
|
go systemCheck(db, noodleChannel)
|
||||||
go tcpProxify(noodleChannel)
|
go expirationCheck(db, noodleChannel)
|
||||||
|
go proxify(db, noodleChannel)
|
||||||
|
|
||||||
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(web.StaticFiles()))))
|
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(web.StaticFiles()))))
|
||||||
http.HandleFunc("/", web.HandleMain(db, &noodleChannel))
|
http.HandleFunc("/login", auth.HandleLogin())
|
||||||
http.HandleFunc("/delete", web.HandleDelete(db, &noodleChannel))
|
http.HandleFunc("/logout", auth.HandleLogout())
|
||||||
|
http.HandleFunc("/", auth.RequireAuth(web.HandleMain(db, &noodleChannel, auth)))
|
||||||
|
http.HandleFunc("/users", auth.RequireAuth(web.HandleUsers(auth, db)))
|
||||||
|
http.HandleFunc("/users/add", auth.RequireAdmin(web.HandleAddUser(auth, db)))
|
||||||
|
http.HandleFunc("/users/role", auth.RequireAdmin(web.HandleSetUserRole(auth, db)))
|
||||||
|
http.HandleFunc("/users/password", auth.RequireAuth(web.HandleChangePassword(auth, db)))
|
||||||
|
http.HandleFunc("/users/delete", auth.RequireAuth(web.HandleDeleteUser(auth, db)))
|
||||||
|
http.HandleFunc("/add", auth.RequireAuth(web.HandleAdd(db, &noodleChannel, auth)))
|
||||||
|
http.HandleFunc("/toggle", auth.RequireAuth(web.HandleToggle(db, &noodleChannel)))
|
||||||
|
http.HandleFunc("/delete", auth.RequireAuth(web.HandleDelete(db, &noodleChannel)))
|
||||||
|
|
||||||
log.Printf("Server starting on %s", listenAddr)
|
log.Printf("Server starting on %s", listenAddr)
|
||||||
return http.ListenAndServe(listenAddr, nil)
|
return http.ListenAndServe(listenAddr, nil)
|
||||||
@ -51,23 +71,22 @@ func systemCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startProxy(proxy *tcpproxy.Proxy) {
|
func proxify(db *noodle.Database, noodleChannel chan noodle.Noodle) {
|
||||||
log.Print(proxy.Run())
|
noodleMap := make(map[string]managedProxy)
|
||||||
}
|
|
||||||
|
|
||||||
func tcpProxify(noodleChannel chan noodle.Noodle) {
|
|
||||||
noodleMap := make(map[string]*tcpproxy.Proxy)
|
|
||||||
for {
|
for {
|
||||||
item := <-noodleChannel
|
item := <-noodleChannel
|
||||||
_, running := noodleMap[item.Id]
|
_, running := noodleMap[item.Id]
|
||||||
if item.IsUp && !running {
|
if item.IsUp && !running {
|
||||||
var proxy tcpproxy.Proxy
|
proxy, err := newProxy(item)
|
||||||
src := fmt.Sprintf("0.0.0.0:%d", item.ListenPort)
|
if err != nil {
|
||||||
dst := fmt.Sprintf("%s:%d", item.DestHost, item.DestPort)
|
log.Print(err)
|
||||||
log.Printf("Starting a noodle from %s to %s", src, dst)
|
if _, updateErr := db.SetIsUp(item.Id, false); updateErr != nil {
|
||||||
proxy.AddRoute(src, tcpproxy.To(dst))
|
log.Print(updateErr)
|
||||||
noodleMap[item.Id] = &proxy
|
}
|
||||||
go startProxy(&proxy)
|
continue
|
||||||
|
}
|
||||||
|
noodleMap[item.Id] = proxy
|
||||||
|
go proxy.Run()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +95,430 @@ func tcpProxify(noodleChannel chan noodle.Noodle) {
|
|||||||
if err := noodleMap[item.Id].Close(); err != nil {
|
if err := noodleMap[item.Id].Close(); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
}
|
}
|
||||||
|
delete(noodleMap, item.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type managedProxy interface {
|
||||||
|
Run()
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProxy(item noodle.Noodle) (managedProxy, error) {
|
||||||
|
listenAddr := fmt.Sprintf("0.0.0.0:%d", item.ListenPort)
|
||||||
|
targetAddr := fmt.Sprintf("%s:%d", item.DestHost, item.DestPort)
|
||||||
|
proto := normalizeProto(item.Proto)
|
||||||
|
log.Printf("Starting a %s noodle from %s to %s with source=%s", proto, listenAddr, targetAddr, item.Src)
|
||||||
|
|
||||||
|
switch proto {
|
||||||
|
case "UDP":
|
||||||
|
return newUDPNoodleProxy(listenAddr, targetAddr, item.Src)
|
||||||
|
default:
|
||||||
|
return newTCPNoodleProxy(listenAddr, targetAddr, item.Src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProto(proto string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(proto)) {
|
||||||
|
case "UDP":
|
||||||
|
return "UDP"
|
||||||
|
default:
|
||||||
|
return "TCP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tcpNoodleProxy struct {
|
||||||
|
listenAddr string
|
||||||
|
targetAddr string
|
||||||
|
allowedIP string
|
||||||
|
listener net.Listener
|
||||||
|
mu sync.Mutex
|
||||||
|
conns map[net.Conn]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTCPNoodleProxy(listenAddr, targetAddr, allowedIP string) (*tcpNoodleProxy, error) {
|
||||||
|
ln, err := net.Listen("tcp", listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tcpNoodleProxy{
|
||||||
|
listenAddr: listenAddr,
|
||||||
|
targetAddr: targetAddr,
|
||||||
|
allowedIP: allowedIP,
|
||||||
|
listener: ln,
|
||||||
|
conns: make(map[net.Conn]struct{}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tcpNoodleProxy) Run() {
|
||||||
|
for {
|
||||||
|
conn, err := p.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if isClosedNetworkError(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Accept failed for %s: %v", p.listenAddr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go p.handleConn(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tcpNoodleProxy) handleConn(src net.Conn) {
|
||||||
|
if !allowSource(p.allowedIP, src.RemoteAddr()) {
|
||||||
|
log.Printf("Rejected noodle connection from %s; allowed source is %s", src.RemoteAddr().String(), p.allowedIP)
|
||||||
|
src.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := net.Dial("tcp", p.targetAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Dial failed from %s to %s: %v", p.listenAddr, p.targetAddr, err)
|
||||||
|
src.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.trackConn(src)
|
||||||
|
p.trackConn(dst)
|
||||||
|
defer p.untrackAndClose(src)
|
||||||
|
defer p.untrackAndClose(dst)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := io.Copy(dst, src); err != nil && !isClosedNetworkError(err) {
|
||||||
|
log.Printf("Proxy copy src->dst failed for %s to %s: %v", p.listenAddr, p.targetAddr, err)
|
||||||
|
}
|
||||||
|
closeWrite(dst)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := io.Copy(src, dst); err != nil && !isClosedNetworkError(err) {
|
||||||
|
log.Printf("Proxy copy dst->src failed for %s to %s: %v", p.targetAddr, p.listenAddr, err)
|
||||||
|
}
|
||||||
|
closeWrite(src)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tcpNoodleProxy) Close() error {
|
||||||
|
err := p.listener.Close()
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for conn := range p.conns {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tcpNoodleProxy) trackConn(conn net.Conn) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.conns[conn] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tcpNoodleProxy) untrackAndClose(conn net.Conn) {
|
||||||
|
p.mu.Lock()
|
||||||
|
delete(p.conns, conn)
|
||||||
|
p.mu.Unlock()
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type udpNoodleProxy struct {
|
||||||
|
listenAddr string
|
||||||
|
targetAddr string
|
||||||
|
allowedIP string
|
||||||
|
conn *net.UDPConn
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]*udpSession
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type udpSession struct {
|
||||||
|
clientAddr *net.UDPAddr
|
||||||
|
backend *net.UDPConn
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const udpSessionIdleTimeout = 2 * time.Minute
|
||||||
|
|
||||||
|
func newUDPNoodleProxy(listenAddr, targetAddr, allowedIP string) (*udpNoodleProxy, error) {
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := net.ListenUDP("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &udpNoodleProxy{
|
||||||
|
listenAddr: listenAddr,
|
||||||
|
targetAddr: targetAddr,
|
||||||
|
allowedIP: allowedIP,
|
||||||
|
conn: conn,
|
||||||
|
sessions: make(map[string]*udpSession),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *udpNoodleProxy) Run() {
|
||||||
|
go p.cleanupIdleSessions()
|
||||||
|
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, clientAddr, err := p.conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
if isClosedNetworkError(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("UDP read failed for %s: %v", p.listenAddr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowSource(p.allowedIP, clientAddr) {
|
||||||
|
log.Printf("Rejected noodle datagram from %s; allowed source is %s", clientAddr.String(), p.allowedIP)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := p.sessionFor(clientAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("UDP session setup failed for %s to %s: %v", clientAddr.String(), p.targetAddr, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := append([]byte(nil), buf[:n]...)
|
||||||
|
if _, err := session.backend.Write(payload); err != nil && !isClosedNetworkError(err) {
|
||||||
|
log.Printf("UDP write failed from %s to %s: %v", clientAddr.String(), p.targetAddr, err)
|
||||||
|
p.removeSession(clientAddr.String(), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *udpNoodleProxy) sessionFor(clientAddr *net.UDPAddr) (*udpSession, error) {
|
||||||
|
key := clientAddr.String()
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if session, ok := p.sessions[key]; ok {
|
||||||
|
session.lastSeen = time.Now()
|
||||||
|
p.mu.Unlock()
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
targetAddr, err := net.ResolveUDPAddr("udp", p.targetAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
backend, err := net.DialUDP("udp", nil, targetAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &udpSession{
|
||||||
|
clientAddr: clientAddr,
|
||||||
|
backend: backend,
|
||||||
|
lastSeen: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if existing, ok := p.sessions[key]; ok {
|
||||||
|
p.mu.Unlock()
|
||||||
|
_ = backend.Close()
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
p.sessions[key] = session
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
go p.relayBackend(session)
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *udpNoodleProxy) relayBackend(session *udpSession) {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, err := session.backend.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if !isClosedNetworkError(err) {
|
||||||
|
log.Printf("UDP backend read failed for %s: %v", p.targetAddr, err)
|
||||||
|
}
|
||||||
|
p.removeSession(session.clientAddr.String(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := p.conn.WriteToUDP(buf[:n], session.clientAddr); err != nil {
|
||||||
|
if !isClosedNetworkError(err) {
|
||||||
|
log.Printf("UDP client write failed for %s: %v", session.clientAddr.String(), err)
|
||||||
|
}
|
||||||
|
p.removeSession(session.clientAddr.String(), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if current, ok := p.sessions[session.clientAddr.String()]; ok {
|
||||||
|
current.lastSeen = time.Now()
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *udpNoodleProxy) cleanupIdleSessions() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
if p.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-udpSessionIdleTimeout)
|
||||||
|
var expired []string
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
for key, session := range p.sessions {
|
||||||
|
if session.lastSeen.Before(cutoff) {
|
||||||
|
expired = append(expired, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
for _, key := range expired {
|
||||||
|
p.removeSession(key, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *udpNoodleProxy) removeSession(key string, closeBackend bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
session, ok := p.sessions[key]
|
||||||
|
if ok {
|
||||||
|
delete(p.sessions, key)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
if ok && closeBackend {
|
||||||
|
_ = session.backend.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *udpNoodleProxy) isClosed() bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *udpNoodleProxy) Close() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.closed {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.closed = true
|
||||||
|
sessions := p.sessions
|
||||||
|
p.sessions = make(map[string]*udpSession)
|
||||||
|
conn := p.conn
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
for _, session := range sessions {
|
||||||
|
_ = session.backend.Close()
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowSource(allowedIPs string, addr net.Addr) bool {
|
||||||
|
if allowedIPs == "" || strings.EqualFold(strings.TrimSpace(allowedIPs), "All") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(addr.String())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hostIP := normalizeIP(net.ParseIP(host))
|
||||||
|
if hostIP == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range strings.Split(allowedIPs, ",") {
|
||||||
|
entry := strings.TrimSpace(item)
|
||||||
|
if strings.Contains(entry, "/") {
|
||||||
|
_, network, err := net.ParseCIDR(entry)
|
||||||
|
if err == nil && networkContainsIP(network, hostIP) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entryIP := normalizeIP(net.ParseIP(entry))
|
||||||
|
if entryIP != nil && entryIP.Equal(hostIP) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIP(ip net.IP) net.IP {
|
||||||
|
if ip == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
return v4
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkContainsIP(network *net.IPNet, ip net.IP) bool {
|
||||||
|
if network == nil || ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if network.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
networkIP := normalizeIP(network.IP)
|
||||||
|
normalizedIP := normalizeIP(ip)
|
||||||
|
if networkIP == nil || normalizedIP == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(networkIP) != len(normalizedIP) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
mask := network.Mask
|
||||||
|
if len(mask) != len(normalizedIP) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range normalizedIP {
|
||||||
|
if normalizedIP[i]&mask[i] != networkIP[i]&mask[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeWrite(conn net.Conn) {
|
||||||
|
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
||||||
|
_ = tcpConn.CloseWrite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isClosedNetworkError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if net.ErrClosed != nil && err == net.ErrClosed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), "use of closed network connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func expirationCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) {
|
||||||
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
stopped := db.TickExpirations()
|
||||||
|
for _, item := range stopped {
|
||||||
|
noodleChannel <- item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,11 +528,12 @@ func runTestSequence(db *noodle.Database) {
|
|||||||
item := noodle.Noodle{
|
item := noodle.Noodle{
|
||||||
Id: db.MakeID(),
|
Id: db.MakeID(),
|
||||||
Name: "Name_Test",
|
Name: "Name_Test",
|
||||||
Proto: "Proto_Test",
|
Proto: "TCP",
|
||||||
|
Src: "All",
|
||||||
ListenPort: 1080 + i,
|
ListenPort: 1080 + i,
|
||||||
DestPort: 22,
|
DestPort: 22,
|
||||||
DestHost: "localhost",
|
DestHost: "localhost",
|
||||||
Expiration: time.Now().Second(),
|
Expiration: time.Duration(time.Now().Second()) * time.Second,
|
||||||
IsUp: true,
|
IsUp: true,
|
||||||
}
|
}
|
||||||
log.Printf("Test noodle=%v", item)
|
log.Printf("Test noodle=%v", item)
|
||||||
|
|||||||
@ -11,8 +11,11 @@
|
|||||||
background-image: url("/static/pub/long-banner.jpg");
|
background-image: url("/static/pub/long-banner.jpg");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 50px;
|
||||||
}
|
}
|
||||||
img.banner {
|
img.banner {
|
||||||
height: 50px;
|
height: 50px;
|
||||||
@ -24,6 +27,15 @@
|
|||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.allow-from-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.allow-from-options .button {
|
||||||
|
height: 1.8rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -34,6 +46,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="banner-container">
|
<div class="banner-container">
|
||||||
<!-- <img class="banner-long" src="/static/pub/long-banner.jpg"/> -->
|
<!-- <img class="banner-long" src="/static/pub/long-banner.jpg"/> -->
|
||||||
|
<div class="navbar-end pr-4">
|
||||||
|
<div class="navbar-item">
|
||||||
|
<span class="has-text-white mr-3">Signed in as {{.Username}}</span>
|
||||||
|
<a class="button is-small is-warning mr-2" href="/users">{{if eq .Role "admin"}}Users{{else}}Account{{end}}</a>
|
||||||
|
<form method="post" action="/logout" autocomplete="off">
|
||||||
|
<button class="button is-small is-light" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@ -43,49 +64,79 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Proto</th>
|
<th>Proto</th>
|
||||||
|
<th>Allow From</th>
|
||||||
<th>Listening Port</th>
|
<th>Listening Port</th>
|
||||||
<th>Dest Port</th>
|
<th>Dest Port</th>
|
||||||
<th>Dest Host/IP</th>
|
<th>Dest Host/IP</th>
|
||||||
<th>Expiration</th>
|
<th>Expiration</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Created By</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><input class="input is-link is-small" type="text" placeholder="Name"/></td>
|
<td><input class="input is-link is-small" type="text" form="add-noodle" name="name" placeholder="Name" autocomplete="off" required/></td>
|
||||||
<td>TCP</td>
|
<td>
|
||||||
<td><input class="input is-link is-small" type="text" placeholder="Listen Port"/></td>
|
<div class="select is-small is-link">
|
||||||
<td><input class="input is-link is-small" type="text" placeholder="Destination Port"/></td>
|
<select form="add-noodle" name="proto" autocomplete="off" required>
|
||||||
<td><input class="input is-link is-small" type="text" placeholder="Destination Host"/></td>
|
<option value="TCP">TCP</option>
|
||||||
<td>Expiration</td>
|
<option value="UDP">UDP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input class="input is-link is-small" type="text" form="add-noodle" name="src" list="allow-from-options" placeholder="All, comma-separated IPs, or CIDRs" value="All" autocomplete="off" required/>
|
||||||
|
<datalist id="allow-from-options">
|
||||||
|
<option value="All"></option>
|
||||||
|
<option value="{{.ClientIP}}"></option>
|
||||||
|
</datalist>
|
||||||
|
<div class="allow-from-options">
|
||||||
|
<button class="button is-small is-white" type="button" onclick="setAllowFromValue('All')">All</button>
|
||||||
|
<button class="button is-small is-white" type="button" onclick="setAllowFromValue('{{.ClientIP}}')">{{.ClientIP}}</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><input class="input is-link is-small" type="text" form="add-noodle" name="listen_port" placeholder="Listen Port" autocomplete="off" required/></td>
|
||||||
|
<td><input class="input is-link is-small" type="text" form="add-noodle" name="dest_port" placeholder="Destination Port" autocomplete="off" required/></td>
|
||||||
|
<td><input class="input is-link is-small" type="text" form="add-noodle" name="dest_host" placeholder="Destination Host" autocomplete="off" required/></td>
|
||||||
|
<td><input class="input is-link is-small" type="text" form="add-noodle" name="expiration" placeholder="30m, 1h15m" autocomplete="off" required/></td>
|
||||||
<td>
|
<td>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="button">
|
</td>
|
||||||
|
<td>
|
||||||
|
<form id="add-noodle" method="post" action="/add" autocomplete="off">
|
||||||
|
<button class="button" type="submit">
|
||||||
<span class="icon has-text-success"><i class="fas fa-plus"></i></span>
|
<span class="icon has-text-success"><i class="fas fa-plus"></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{range .}}
|
{{range .Noodles}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.Name}}</td>
|
<td>{{.Name}}</td>
|
||||||
<td>{{.Proto}}</td>
|
<td>{{.Proto}}</td>
|
||||||
|
<td>{{.Src}}</td>
|
||||||
<td>{{.ListenPort}}</td>
|
<td>{{.ListenPort}}</td>
|
||||||
<td>{{.DestPort}}</td>
|
<td>{{.DestPort}}</td>
|
||||||
<td>{{.DestHost}}</td>
|
<td>{{.DestHost}}</td>
|
||||||
<td>{{.Expiration}}</td>
|
<td data-expiration-ms="{{.Expiration.Milliseconds}}" data-is-up="{{.IsUp}}">{{.Expiration}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if .IsUp}}
|
<form method="post" action="/toggle" autocomplete="off">
|
||||||
<span class="icon has-text-success"><i class="fas fa-check-square"></i></span>
|
<input type="hidden" name="id" value="{{.Id}}"/>
|
||||||
{{ else }}
|
<label>
|
||||||
<span class="icon has-text-danger"><i class="fas fa-ban"></i></span>
|
<input type="checkbox" name="is_up" onchange="this.form.submit()" {{if .IsUp}}checked{{end}}/>
|
||||||
{{ end }}
|
</label>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{.CreatedBy}}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/delete?id={{.Id}}">
|
<form method="post" action="/delete" autocomplete="off" onsubmit="return confirm('Delete this proxy?');">
|
||||||
|
<input type="hidden" name="id" value="{{.Id}}"/>
|
||||||
|
<button class="button is-white" type="submit" aria-label="Delete noodle">
|
||||||
<span class="icon has-text-danger"><i class="fas fa-minus"></i></span>
|
<span class="icon has-text-danger"><i class="fas fa-minus"></i></span>
|
||||||
</a>
|
</button>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -95,10 +146,72 @@
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="content has-text-centered">
|
<div class="content has-text-centered">
|
||||||
<p>
|
<p>
|
||||||
<strong>Infinite-Noodles Network Proxy</strong> - © 2025 Jimmy Allen
|
<strong>Infinite-Noodles Network Proxy</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
<script>
|
||||||
|
function setAllowFromValue(value) {
|
||||||
|
const input = document.querySelector('input[name="src"]');
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = value;
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (ms <= 0) {
|
||||||
|
return "0s";
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
totalSeconds -= hours * 3600;
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
parts.push(hours + "h");
|
||||||
|
}
|
||||||
|
if (minutes > 0 || hours > 0) {
|
||||||
|
parts.push(minutes + "m");
|
||||||
|
}
|
||||||
|
parts.push(seconds + "s");
|
||||||
|
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExpirationCountdowns() {
|
||||||
|
const expirationCells = document.querySelectorAll("[data-expiration-ms]");
|
||||||
|
expirationCells.forEach((cell) => {
|
||||||
|
if (cell.dataset.isUp !== "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextValue = Number(cell.dataset.expirationMs) - 1000;
|
||||||
|
const clampedValue = Math.max(nextValue, 0);
|
||||||
|
cell.dataset.expirationMs = String(clampedValue);
|
||||||
|
if (clampedValue === 0) {
|
||||||
|
const row = cell.closest("tr");
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cell.textContent = formatDuration(clampedValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const expirationCells = document.querySelectorAll("[data-expiration-ms]");
|
||||||
|
expirationCells.forEach((cell) => {
|
||||||
|
cell.textContent = formatDuration(Number(cell.dataset.expirationMs));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setInterval(updateExpirationCountdowns, 1000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
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
|
package noodle
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.mills.io/bitcask/v2"
|
"go.mills.io/bitcask/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrUserNotFound = errors.New("user not found")
|
||||||
|
var ErrLastAdminRemoval = errors.New("cannot remove the last admin")
|
||||||
|
|
||||||
type Database struct {
|
type Database struct {
|
||||||
connection *bitcask.Bitcask
|
connection *bitcask.Bitcask
|
||||||
Handle *bitcask.Collection
|
Handle *bitcask.Collection
|
||||||
|
Users *bitcask.Collection
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDatabase(path string) *Database {
|
func NewDatabase(path string) *Database {
|
||||||
@ -20,6 +29,7 @@ func NewDatabase(path string) *Database {
|
|||||||
return &Database{
|
return &Database{
|
||||||
connection: db,
|
connection: db,
|
||||||
Handle: db.Collection("noodles"),
|
Handle: db.Collection("noodles"),
|
||||||
|
Users: db.Collection("users"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,6 +43,9 @@ func (db *Database) MakeID() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *Database) GetAll() []Noodle {
|
func (db *Database) GetAll() []Noodle {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
var data []Noodle
|
var data []Noodle
|
||||||
if err := db.Handle.List(&data); err != nil {
|
if err := db.Handle.List(&data); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
@ -42,6 +55,9 @@ func (db *Database) GetAll() []Noodle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *Database) GetAllGeneric() []interface{} {
|
func (db *Database) GetAllGeneric() []interface{} {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
var data []interface{}
|
var data []interface{}
|
||||||
if err := db.Handle.List(&data); err != nil {
|
if err := db.Handle.List(&data); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
@ -51,6 +67,9 @@ func (db *Database) GetAllGeneric() []interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *Database) Get(id string) Noodle {
|
func (db *Database) Get(id string) Noodle {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
var item Noodle
|
var item Noodle
|
||||||
log.Printf("Looking up noodle key='%s'", id)
|
log.Printf("Looking up noodle key='%s'", id)
|
||||||
log.Printf("key='%s' exists=%t", id, db.Handle.Has(id))
|
log.Printf("key='%s' exists=%t", id, db.Handle.Has(id))
|
||||||
@ -62,6 +81,20 @@ func (db *Database) Get(id string) Noodle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *Database) Add(item Noodle) error {
|
func (db *Database) Add(item Noodle) error {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
if err := db.Handle.Add(item.Id, item); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) Update(item Noodle) error {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
if err := db.Handle.Add(item.Id, item); err != nil {
|
if err := db.Handle.Add(item.Id, item); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return err
|
return err
|
||||||
@ -70,6 +103,9 @@ func (db *Database) Add(item Noodle) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *Database) Delete(id string) error {
|
func (db *Database) Delete(id string) error {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
if err := db.Handle.Delete(id); err != nil {
|
if err := db.Handle.Delete(id); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return err
|
return err
|
||||||
@ -80,3 +116,248 @@ func (db *Database) Delete(id string) error {
|
|||||||
func (db *Database) Close() error {
|
func (db *Database) Close() error {
|
||||||
return db.connection.Close()
|
return db.connection.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Database) GetAllUsers() []User {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var data []User
|
||||||
|
if err := db.Users.List(&data); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) GetUserByUsername(username string) (User, error) {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var data []User
|
||||||
|
if err := db.Users.List(&data); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range data {
|
||||||
|
if user.Username == username {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return User{}, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) AddUser(user User) error {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var data []User
|
||||||
|
if err := db.Users.List(&data); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, existing := range data {
|
||||||
|
if existing.Username == user.Username {
|
||||||
|
return fmt.Errorf("user %q already exists", user.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Users.Add(user.Id, user); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) UpdateUser(user User) error {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
if err := db.Users.Add(user.Id, user); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) SetUserRole(username, role string) (User, error) {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var data []User
|
||||||
|
if err := db.Users.List(&data); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var target User
|
||||||
|
found := false
|
||||||
|
adminCount := 0
|
||||||
|
for _, user := range data {
|
||||||
|
if user.Role == UserRoleAdmin {
|
||||||
|
adminCount++
|
||||||
|
}
|
||||||
|
if user.Username == username {
|
||||||
|
target = user
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return User{}, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if target.Role == UserRoleAdmin && role != UserRoleAdmin && adminCount <= 1 {
|
||||||
|
return User{}, ErrLastAdminRemoval
|
||||||
|
}
|
||||||
|
|
||||||
|
target.Role = role
|
||||||
|
if err := db.Users.Add(target.Id, target); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) SetUserPassword(username, passwordHash string) (User, error) {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var data []User
|
||||||
|
if err := db.Users.List(&data); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range data {
|
||||||
|
if user.Username != username {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user.PasswordHash = passwordHash
|
||||||
|
if err := db.Users.Add(user.Id, user); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return User{}, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) DeleteUser(username string) (User, error) {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var data []User
|
||||||
|
if err := db.Users.List(&data); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var target User
|
||||||
|
found := false
|
||||||
|
adminCount := 0
|
||||||
|
for _, user := range data {
|
||||||
|
if user.Role == UserRoleAdmin {
|
||||||
|
adminCount++
|
||||||
|
}
|
||||||
|
if user.Username == username {
|
||||||
|
target = user
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return User{}, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if target.Role == UserRoleAdmin && adminCount <= 1 {
|
||||||
|
return User{}, ErrLastAdminRemoval
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Users.Delete(target.Id); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) SetIsUp(id string, isUp bool) (Noodle, error) {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var item Noodle
|
||||||
|
if err := db.Handle.Get(id, &item); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return Noodle{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item.IsUp = isUp
|
||||||
|
if err := db.Handle.Add(item.Id, item); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return Noodle{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) DeleteByID(id string) (Noodle, error) {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var item Noodle
|
||||||
|
if err := db.Handle.Get(id, &item); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return Noodle{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Handle.Delete(id); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return Noodle{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) TickExpirations() []Noodle {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
|
||||||
|
var noodles []Noodle
|
||||||
|
if err := db.Handle.List(&noodles); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stopped []Noodle
|
||||||
|
for _, item := range noodles {
|
||||||
|
if !item.IsUp {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Expiration <= 0 {
|
||||||
|
item.IsUp = false
|
||||||
|
stopped = append(stopped, item)
|
||||||
|
if err := db.Handle.Delete(item.Id); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Expiration -= time.Second
|
||||||
|
if item.Expiration <= 0 {
|
||||||
|
item.Expiration = 0
|
||||||
|
item.IsUp = false
|
||||||
|
stopped = append(stopped, item)
|
||||||
|
if err := db.Handle.Delete(item.Id); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Handle.Add(item.Id, item); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stopped
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,29 @@
|
|||||||
package noodle
|
package noodle
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserRoleRegular = "regular"
|
||||||
|
UserRoleAdmin = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
type Noodle struct {
|
type Noodle struct {
|
||||||
Id string
|
Id string
|
||||||
Name string
|
Name string
|
||||||
Proto string
|
Proto string
|
||||||
|
Src string
|
||||||
ListenPort int
|
ListenPort int
|
||||||
DestPort int
|
DestPort int
|
||||||
DestHost string
|
DestHost string
|
||||||
Expiration int
|
Expiration time.Duration
|
||||||
IsUp bool
|
IsUp bool
|
||||||
|
CreatedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Id string
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
PasswordHash string
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
278
internal/web/auth.go
Normal file
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))
|
||||||
|
}
|
||||||
@ -4,7 +4,11 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"infinite-noodle/internal/assets"
|
"infinite-noodle/internal/assets"
|
||||||
"infinite-noodle/internal/noodle"
|
"infinite-noodle/internal/noodle"
|
||||||
@ -14,43 +18,462 @@ func StaticFiles() fs.FS {
|
|||||||
return assets.FS
|
return assets.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleMain(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) {
|
type indexPageData struct {
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
ClientIP string
|
||||||
|
Noodles []noodle.Noodle
|
||||||
|
}
|
||||||
|
|
||||||
|
type userConfigPageData struct {
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
Users []noodle.User
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleMain(db *noodle.Database, pc *chan noodle.Noodle, auth *Auth) func(w http.ResponseWriter, req *http.Request) {
|
||||||
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
|
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error parsing templates: %v", err)
|
log.Fatalf("Error parsing templates: %v", err)
|
||||||
}
|
}
|
||||||
return func(w http.ResponseWriter, req *http.Request) {
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
data := db.GetAll()
|
currentUser, _ := auth.CurrentUser(req)
|
||||||
|
data := indexPageData{
|
||||||
|
Username: currentUser.Username,
|
||||||
|
Role: currentUser.Role,
|
||||||
|
ClientIP: clientIPFromRequest(req),
|
||||||
|
Noodles: db.GetAll(),
|
||||||
|
}
|
||||||
if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
|
if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleDelete(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) {
|
func HandleUsers(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
|
||||||
return func(w http.ResponseWriter, req *http.Request) {
|
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
|
||||||
q := req.URL.Query()
|
if err != nil {
|
||||||
vals, ok := q["id"]
|
log.Fatalf("Error parsing templates: %v", err)
|
||||||
if ok {
|
|
||||||
id := vals[0]
|
|
||||||
item := db.Get(id)
|
|
||||||
item.IsUp = false
|
|
||||||
*pc <- item
|
|
||||||
log.Printf("Deleting noodle=%v", item)
|
|
||||||
db.Delete(item.Id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
currentUser, _ := auth.CurrentUser(req)
|
||||||
|
data := userConfigPageData{
|
||||||
|
Username: currentUser.Username,
|
||||||
|
Role: currentUser.Role,
|
||||||
|
}
|
||||||
|
if currentUser.Role == noodle.UserRoleAdmin {
|
||||||
|
data.Users = db.GetAllUsers()
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "users.html", data); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleAdd(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) {
|
func HandleAddUser(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error parsing templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
currentUser, _ := auth.CurrentUser(req)
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(req.FormValue("username"))
|
||||||
|
password := req.FormValue("password")
|
||||||
|
role := normalizeUserRole(req.FormValue("role"))
|
||||||
|
if username == "" || password == "" {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, "username and password are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, "invalid user role", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := noodle.User{
|
||||||
|
Id: db.MakeID(),
|
||||||
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
PasswordHash: HashPassword(password),
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := db.AddUser(user); err != nil {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, req, "/users", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleSetUserRole(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error parsing templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
currentUser, _ := auth.CurrentUser(req)
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(req.FormValue("username"))
|
||||||
|
role := normalizeUserRole(req.FormValue("role"))
|
||||||
|
if username == "" {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, "username is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, "invalid user role", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.SetUserRole(username, role); err != nil {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, req, "/users", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleChangePassword(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error parsing templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
currentUser, _ := auth.CurrentUser(req)
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUsername := strings.TrimSpace(req.FormValue("username"))
|
||||||
|
password := req.FormValue("password")
|
||||||
|
confirmPassword := req.FormValue("confirm_password")
|
||||||
|
if targetUsername == "" || password == "" {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, "username and password are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if confirmPassword != "" && password != confirmPassword {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, "password confirmation does not match", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentUser.Role != noodle.UserRoleAdmin && targetUsername != currentUser.Username {
|
||||||
|
http.Error(w, "cannot change another user's password", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.SetUserPassword(targetUsername, HashPassword(password)); err != nil {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, req, "/users", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleDeleteUser(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error parsing templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
currentUser, _ := auth.CurrentUser(req)
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentUser.Role != noodle.UserRoleAdmin {
|
||||||
|
http.Error(w, "admin access required", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(req.FormValue("username"))
|
||||||
|
if username == "" {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, "username is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.DeleteUser(username); err != nil {
|
||||||
|
renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if username == currentUser.Username {
|
||||||
|
auth.ClearSession(w)
|
||||||
|
http.Redirect(w, req, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, req, "/users", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleDelete(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) {
|
||||||
return func(w http.ResponseWriter, req *http.Request) {
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := req.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimSpace(req.FormValue("id"))
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "missing noodle id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := db.DeleteByID(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "noodle not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.IsUp = false
|
||||||
|
*pc <- item
|
||||||
|
log.Printf("Deleting noodle=%v", item)
|
||||||
|
|
||||||
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
|
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HandleAdd(db *noodle.Database, pc *chan noodle.Noodle, auth *Auth) func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
currentUser, _ := auth.CurrentUser(req)
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listenPort, err := strconv.Atoi(strings.TrimSpace(req.FormValue("listen_port")))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid listen port", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isValidPort(listenPort) {
|
||||||
|
http.Error(w, "listen port must be between 1 and 65535", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
destPort, err := strconv.Atoi(strings.TrimSpace(req.FormValue("dest_port")))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid destination port", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isValidPort(destPort) {
|
||||||
|
http.Error(w, "destination port must be between 1 and 65535", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expiration, err := time.ParseDuration(strings.TrimSpace(req.FormValue("expiration")))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid expiration duration", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proto := strings.ToUpper(strings.TrimSpace(req.FormValue("proto")))
|
||||||
|
if proto != "TCP" && proto != "UDP" {
|
||||||
|
http.Error(w, "invalid protocol", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := normalizeSourceList(req.FormValue("src"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid source restriction", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
destHost := strings.TrimSpace(req.FormValue("dest_host"))
|
||||||
|
if destHost == "" {
|
||||||
|
http.Error(w, "destination host is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item := noodle.Noodle{
|
||||||
|
Id: db.MakeID(),
|
||||||
|
Name: strings.TrimSpace(req.FormValue("name")),
|
||||||
|
Proto: proto,
|
||||||
|
Src: src,
|
||||||
|
ListenPort: listenPort,
|
||||||
|
DestPort: destPort,
|
||||||
|
DestHost: destHost,
|
||||||
|
Expiration: expiration,
|
||||||
|
IsUp: true,
|
||||||
|
CreatedBy: currentUser.Username,
|
||||||
|
}
|
||||||
|
if err := db.Add(item); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*pc <- item
|
||||||
|
|
||||||
|
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleToggle(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimSpace(req.FormValue("id"))
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "missing noodle id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := db.SetIsUp(id, req.FormValue("is_up") == "on")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "noodle not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*pc <- item
|
||||||
|
|
||||||
|
http.Redirect(w, req, "/", http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIPFromRequest(req *http.Request) string {
|
||||||
|
forwardedFor := strings.TrimSpace(req.Header.Get("X-Forwarded-For"))
|
||||||
|
if forwardedFor != "" {
|
||||||
|
parts := strings.Split(forwardedFor, ",")
|
||||||
|
ip := strings.TrimSpace(parts[0])
|
||||||
|
if parsed := net.ParseIP(ip); parsed != nil {
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
realIP := strings.TrimSpace(req.Header.Get("X-Real-Ip"))
|
||||||
|
if realIP != "" {
|
||||||
|
if parsed := net.ParseIP(realIP); parsed != nil {
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host, _, err := net.SplitHostPort(strings.TrimSpace(req.RemoteAddr))
|
||||||
|
if err == nil {
|
||||||
|
if parsed := net.ParseIP(host); parsed != nil {
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed := net.ParseIP(strings.TrimSpace(req.RemoteAddr)); parsed != nil {
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidPort(port int) bool {
|
||||||
|
return port >= 1 && port <= 65535
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSourceList(raw string) (string, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(raw, "All") {
|
||||||
|
return "All", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
normalized := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
entry := strings.TrimSpace(part)
|
||||||
|
if entry == "" {
|
||||||
|
return "", net.InvalidAddrError("empty source")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(entry, "/") {
|
||||||
|
_, network, err := net.ParseCIDR(entry)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
normalized = append(normalized, network.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := net.ParseIP(entry)
|
||||||
|
if parsed == nil {
|
||||||
|
return "", net.InvalidAddrError(entry)
|
||||||
|
}
|
||||||
|
normalized = append(normalized, parsed.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(normalized, ", "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUserRole(role string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||||
|
case noodle.UserRoleAdmin:
|
||||||
|
return noodle.UserRoleAdmin
|
||||||
|
case noodle.UserRoleRegular:
|
||||||
|
return noodle.UserRoleRegular
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderUsersPage(w http.ResponseWriter, tmpl *template.Template, db *noodle.Database, currentUser authUser, message string, status int) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
data := userConfigPageData{
|
||||||
|
Username: currentUser.Username,
|
||||||
|
Role: currentUser.Role,
|
||||||
|
Error: message,
|
||||||
|
}
|
||||||
|
if currentUser.Role == noodle.UserRoleAdmin {
|
||||||
|
data.Users = db.GetAllUsers()
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "users.html", data); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user