Add editable proxy controls to UI

This commit is contained in:
2026-03-30 21:08:31 -04:00
parent ded36aa0e0
commit 0f3803b0e6
5 changed files with 305 additions and 21 deletions

View File

@ -3,6 +3,7 @@ package app
import ( import (
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"time" "time"
@ -31,10 +32,13 @@ func Run(cfg Config) error {
} }
go systemCheck(db, noodleChannel) go systemCheck(db, noodleChannel)
go expirationCheck(db, noodleChannel)
go tcpProxify(noodleChannel) go tcpProxify(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("/", 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("/delete", web.HandleDelete(db, &noodleChannel))
log.Printf("Server starting on %s", listenAddr) log.Printf("Server starting on %s", listenAddr)
@ -64,8 +68,11 @@ func tcpProxify(noodleChannel chan noodle.Noodle) {
var proxy tcpproxy.Proxy var proxy tcpproxy.Proxy
src := fmt.Sprintf("0.0.0.0:%d", item.ListenPort) src := fmt.Sprintf("0.0.0.0:%d", item.ListenPort)
dst := fmt.Sprintf("%s:%d", item.DestHost, item.DestPort) dst := fmt.Sprintf("%s:%d", item.DestHost, item.DestPort)
log.Printf("Starting a noodle from %s to %s", src, dst) log.Printf("Starting a noodle from %s to %s with source=%s", src, dst, item.Src)
proxy.AddRoute(src, tcpproxy.To(dst)) proxy.AddRoute(src, sourceRestrictedTarget{
allowedIP: item.Src,
target: tcpproxy.To(dst),
})
noodleMap[item.Id] = &proxy noodleMap[item.Id] = &proxy
go startProxy(&proxy) go startProxy(&proxy)
continue continue
@ -76,6 +83,78 @@ 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 sourceRestrictedTarget struct {
allowedIP string
target tcpproxy.Target
}
func (t sourceRestrictedTarget) HandleConn(conn net.Conn) {
if t.allowedIP == "" || t.allowedIP == "All" {
t.target.HandleConn(conn)
return
}
host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil {
log.Printf("Rejected noodle connection with invalid remote address %q", conn.RemoteAddr().String())
conn.Close()
return
}
if host != t.allowedIP {
log.Printf("Rejected noodle connection from %s; allowed source is %s", host, t.allowedIP)
conn.Close()
return
}
t.target.HandleConn(conn)
}
func expirationCheck(db *noodle.Database, noodleChannel chan noodle.Noodle) {
ticker := time.NewTicker(1 * time.Second)
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)
}
} }
} }
} }
@ -86,10 +165,11 @@ func runTestSequence(db *noodle.Database) {
Id: db.MakeID(), Id: db.MakeID(),
Name: "Name_Test", Name: "Name_Test",
Proto: "Proto_Test", Proto: "Proto_Test",
Src: "All",
ListenPort: 1080 + i, ListenPort: 1080 + i,
DestPort: 22, DestPort: 22,
DestHost: "localhost", DestHost: "localhost",
Expiration: time.Now().Second(), Expiration: time.Duration(time.Now().Second()) * time.Second,
IsUp: true, IsUp: true,
} }
log.Printf("Test noodle=%v", item) log.Printf("Test noodle=%v", item)

View File

@ -43,6 +43,7 @@
<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>
@ -53,34 +54,45 @@
</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>TCP</td>
<td><input class="input is-link is-small" type="text" placeholder="Listen Port"/></td> <td>
<td><input class="input is-link is-small" type="text" placeholder="Destination Port"/></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/>
<td><input class="input is-link is-small" type="text" placeholder="Destination Host"/></td> <datalist id="allow-from-options">
<td>Expiration</td> <option value="All"></option>
<option value="{{.ClientIP}}"></option>
</datalist>
</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"> <form id="add-noodle" method="post" action="/add" autocomplete="off">
<span class="icon has-text-success"><i class="fas fa-plus"></i></span> <button class="button" type="submit">
</button> <span class="icon has-text-success"><i class="fas fa-plus"></i></span>
</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> <td>
<a href="/delete?id={{.Id}}"> <a href="/delete?id={{.Id}}">
@ -99,6 +111,59 @@
</p> </p>
</div> </div>
</footer> </footer>
<script>
function formatDuration(ms) {
if (ms <= 0) {
return "0s";
}
let totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
totalSeconds -= hours * 3600;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const parts = [];
if (hours > 0) {
parts.push(hours + "h");
}
if (minutes > 0 || hours > 0) {
parts.push(minutes + "m");
}
parts.push(seconds + "s");
return parts.join("");
}
function updateExpirationCountdowns() {
const expirationCells = document.querySelectorAll("[data-expiration-ms]");
expirationCells.forEach((cell) => {
if (cell.dataset.isUp !== "true") {
return;
}
const nextValue = Number(cell.dataset.expirationMs) - 1000;
const clampedValue = Math.max(nextValue, 0);
cell.dataset.expirationMs = String(clampedValue);
if (clampedValue === 0) {
const row = cell.closest("tr");
if (row) {
row.remove();
}
return;
}
cell.textContent = formatDuration(clampedValue);
});
}
document.addEventListener("DOMContentLoaded", () => {
const expirationCells = document.querySelectorAll("[data-expiration-ms]");
expirationCells.forEach((cell) => {
cell.textContent = formatDuration(Number(cell.dataset.expirationMs));
});
window.setInterval(updateExpirationCountdowns, 1000);
});
</script>
</body> </body>
</html> </html>

View File

@ -69,6 +69,14 @@ func (db *Database) Add(item Noodle) error {
return nil return nil
} }
func (db *Database) Update(item Noodle) error {
if err := db.Handle.Add(item.Id, item); err != nil {
log.Print(err)
return err
}
return nil
}
func (db *Database) Delete(id string) error { func (db *Database) Delete(id string) error {
if err := db.Handle.Delete(id); err != nil { if err := db.Handle.Delete(id); err != nil {
log.Print(err) log.Print(err)

View File

@ -1,12 +1,15 @@
package noodle package noodle
import "time"
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
} }

View File

@ -4,7 +4,11 @@ import (
"html/template" "html/template"
"io/fs" "io/fs"
"log" "log"
"net"
"net/http" "net/http"
"strconv"
"strings"
"time"
"infinite-noodle/internal/assets" "infinite-noodle/internal/assets"
"infinite-noodle/internal/noodle" "infinite-noodle/internal/noodle"
@ -14,13 +18,21 @@ func StaticFiles() fs.FS {
return assets.FS return assets.FS
} }
type indexPageData struct {
ClientIP string
Noodles []noodle.Noodle
}
func HandleMain(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) { func HandleMain(db *noodle.Database, pc *chan noodle.Noodle) 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() data := indexPageData{
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)
} }
@ -51,6 +63,122 @@ func HandleAdd(db *noodle.Database, pc *chan noodle.Noodle) func(w http.Response
return 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
}
destPort, err := strconv.Atoi(strings.TrimSpace(req.FormValue("dest_port")))
if err != nil {
http.Error(w, "invalid destination port", http.StatusBadRequest)
return
}
expiration, err := time.ParseDuration(strings.TrimSpace(req.FormValue("expiration")))
if err != nil {
http.Error(w, "invalid expiration duration", http.StatusBadRequest)
return
}
clientIP := clientIPFromRequest(req)
src := strings.TrimSpace(req.FormValue("src"))
if src == clientIP {
src = clientIP
}
if src != "All" && net.ParseIP(src) == nil {
http.Error(w, "invalid source restriction", http.StatusBadRequest)
return
}
item := noodle.Noodle{
Id: db.MakeID(),
Name: strings.TrimSpace(req.FormValue("name")),
Proto: "TCP",
Src: src,
ListenPort: listenPort,
DestPort: destPort,
DestHost: strings.TrimSpace(req.FormValue("dest_host")),
Expiration: expiration,
IsUp: true,
}
if err := db.Add(item); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
*pc <- item
http.Redirect(w, req, "/", http.StatusTemporaryRedirect) 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 := db.Get(id)
if item.Id == "" {
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)
}
}
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"
}