Add editable proxy controls to UI
This commit is contained in:
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user