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 (
"fmt"
"log"
"net"
"net/http"
"time"
@ -31,10 +32,13 @@ func Run(cfg Config) error {
}
go systemCheck(db, noodleChannel)
go expirationCheck(db, noodleChannel)
go tcpProxify(noodleChannel)
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(web.StaticFiles()))))
http.HandleFunc("/", web.HandleMain(db, &noodleChannel))
http.HandleFunc("/add", web.HandleAdd(db, &noodleChannel))
http.HandleFunc("/toggle", web.HandleToggle(db, &noodleChannel))
http.HandleFunc("/delete", web.HandleDelete(db, &noodleChannel))
log.Printf("Server starting on %s", listenAddr)
@ -64,8 +68,11 @@ func tcpProxify(noodleChannel chan noodle.Noodle) {
var proxy tcpproxy.Proxy
src := fmt.Sprintf("0.0.0.0:%d", item.ListenPort)
dst := fmt.Sprintf("%s:%d", item.DestHost, item.DestPort)
log.Printf("Starting a noodle from %s to %s", src, dst)
proxy.AddRoute(src, tcpproxy.To(dst))
log.Printf("Starting a noodle from %s to %s with source=%s", src, dst, item.Src)
proxy.AddRoute(src, sourceRestrictedTarget{
allowedIP: item.Src,
target: tcpproxy.To(dst),
})
noodleMap[item.Id] = &proxy
go startProxy(&proxy)
continue
@ -76,6 +83,78 @@ func tcpProxify(noodleChannel chan noodle.Noodle) {
if err := noodleMap[item.Id].Close(); err != nil {
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(),
Name: "Name_Test",
Proto: "Proto_Test",
Src: "All",
ListenPort: 1080 + i,
DestPort: 22,
DestHost: "localhost",
Expiration: time.Now().Second(),
Expiration: time.Duration(time.Now().Second()) * time.Second,
IsUp: true,
}
log.Printf("Test noodle=%v", item)

View File

@ -43,6 +43,7 @@
<tr>
<th>Name</th>
<th>Proto</th>
<th>Allow From</th>
<th>Listening Port</th>
<th>Dest Port</th>
<th>Dest Host/IP</th>
@ -53,34 +54,45 @@
</thead>
<tbody>
<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><input class="input is-link is-small" type="text" placeholder="Listen Port"/></td>
<td><input class="input is-link is-small" type="text" placeholder="Destination Port"/></td>
<td><input class="input is-link is-small" type="text" placeholder="Destination Host"/></td>
<td>Expiration</td>
<td>
<input class="input is-link is-small" type="text" form="add-noodle" name="src" list="allow-from-options" placeholder="All or source IP" value="All" autocomplete="off" required/>
<datalist id="allow-from-options">
<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>
<button class="button">
<span class="icon has-text-success"><i class="fas fa-plus"></i></span>
</button>
<form id="add-noodle" method="post" action="/add" autocomplete="off">
<button class="button" type="submit">
<span class="icon has-text-success"><i class="fas fa-plus"></i></span>
</button>
</form>
</td>
</tr>
{{range .}}
{{range .Noodles}}
<tr>
<td>{{.Name}}</td>
<td>{{.Proto}}</td>
<td>{{.Src}}</td>
<td>{{.ListenPort}}</td>
<td>{{.DestPort}}</td>
<td>{{.DestHost}}</td>
<td>{{.Expiration}}</td>
<td data-expiration-ms="{{.Expiration.Milliseconds}}" data-is-up="{{.IsUp}}">{{.Expiration}}</td>
<td>
{{if .IsUp}}
<span class="icon has-text-success"><i class="fas fa-check-square"></i></span>
{{ else }}
<span class="icon has-text-danger"><i class="fas fa-ban"></i></span>
{{ end }}
<form method="post" action="/toggle" autocomplete="off">
<input type="hidden" name="id" value="{{.Id}}"/>
<label>
<input type="checkbox" name="is_up" onchange="this.form.submit()" {{if .IsUp}}checked{{end}}/>
</label>
</form>
</td>
<td>
<a href="/delete?id={{.Id}}">
@ -99,6 +111,59 @@
</p>
</div>
</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>
</html>
</html>

View File

@ -69,6 +69,14 @@ func (db *Database) Add(item Noodle) error {
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 {
if err := db.Handle.Delete(id); err != nil {
log.Print(err)

View File

@ -1,12 +1,15 @@
package noodle
import "time"
type Noodle struct {
Id string
Name string
Proto string
Src string
ListenPort int
DestPort int
DestHost string
Expiration int
Expiration time.Duration
IsUp bool
}

View File

@ -4,7 +4,11 @@ import (
"html/template"
"io/fs"
"log"
"net"
"net/http"
"strconv"
"strings"
"time"
"infinite-noodle/internal/assets"
"infinite-noodle/internal/noodle"
@ -14,13 +18,21 @@ func StaticFiles() fs.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) {
tmpl, err := template.ParseFS(assets.FS, "templates/*.html")
if err != nil {
log.Fatalf("Error parsing templates: %v", err)
}
return func(w http.ResponseWriter, req *http.Request) {
data := db.GetAll()
data := indexPageData{
ClientIP: clientIPFromRequest(req),
Noodles: db.GetAll(),
}
if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
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
}
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)
}
}
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"
}