package web import ( "html/template" "io/fs" "log" "net" "net/http" "strconv" "strings" "time" "infinite-noodle/internal/assets" "infinite-noodle/internal/noodle" ) func StaticFiles() fs.FS { return assets.FS } 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") if err != nil { log.Fatalf("Error parsing templates: %v", err) } return func(w http.ResponseWriter, req *http.Request) { currentUser, _ := auth.CurrentUser(req) data := indexPageData{ Username: currentUser.Username, Role: currentUser.Role, ClientIP: clientIPFromRequest(req), Noodles: db.GetAll(), } if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func HandleUsers(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) { tmpl, err := template.ParseFS(assets.FS, "templates/*.html") if err != nil { log.Fatalf("Error parsing templates: %v", err) } return func(w http.ResponseWriter, req *http.Request) { currentUser, _ := auth.CurrentUser(req) data := userConfigPageData{ Username: currentUser.Username, Role: currentUser.Role, } if currentUser.Role == noodle.UserRoleAdmin { data.Users = db.GetAllUsers() } if err := tmpl.ExecuteTemplate(w, "users.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func HandleAddUser(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) { tmpl, err := template.ParseFS(assets.FS, "templates/*.html") if err != nil { log.Fatalf("Error parsing templates: %v", err) } return func(w http.ResponseWriter, req *http.Request) { currentUser, _ := auth.CurrentUser(req) if req.Method != http.MethodPost { http.Error(w, "", http.StatusMethodNotAllowed) return } if err := req.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } username := strings.TrimSpace(req.FormValue("username")) password := req.FormValue("password") role := normalizeUserRole(req.FormValue("role")) if username == "" || password == "" { renderUsersPage(w, tmpl, db, currentUser, "username and password are required", http.StatusBadRequest) return } if role == "" { renderUsersPage(w, tmpl, db, currentUser, "invalid user role", http.StatusBadRequest) return } user := noodle.User{ Id: db.MakeID(), Username: username, Role: role, PasswordHash: HashPassword(password), CreatedAt: time.Now().UTC(), } if err := db.AddUser(user); err != nil { renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest) return } http.Redirect(w, req, "/users", http.StatusSeeOther) } } func HandleSetUserRole(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) { tmpl, err := template.ParseFS(assets.FS, "templates/*.html") if err != nil { log.Fatalf("Error parsing templates: %v", err) } return func(w http.ResponseWriter, req *http.Request) { currentUser, _ := auth.CurrentUser(req) if req.Method != http.MethodPost { http.Error(w, "", http.StatusMethodNotAllowed) return } if err := req.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } username := strings.TrimSpace(req.FormValue("username")) role := normalizeUserRole(req.FormValue("role")) if username == "" { renderUsersPage(w, tmpl, db, currentUser, "username is required", http.StatusBadRequest) return } if role == "" { renderUsersPage(w, tmpl, db, currentUser, "invalid user role", http.StatusBadRequest) return } if _, err := db.SetUserRole(username, role); err != nil { renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest) return } http.Redirect(w, req, "/users", http.StatusSeeOther) } } func HandleChangePassword(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) { tmpl, err := template.ParseFS(assets.FS, "templates/*.html") if err != nil { log.Fatalf("Error parsing templates: %v", err) } return func(w http.ResponseWriter, req *http.Request) { currentUser, _ := auth.CurrentUser(req) if req.Method != http.MethodPost { http.Error(w, "", http.StatusMethodNotAllowed) return } if err := req.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } targetUsername := strings.TrimSpace(req.FormValue("username")) password := req.FormValue("password") confirmPassword := req.FormValue("confirm_password") if targetUsername == "" || password == "" { renderUsersPage(w, tmpl, db, currentUser, "username and password are required", http.StatusBadRequest) return } if confirmPassword != "" && password != confirmPassword { renderUsersPage(w, tmpl, db, currentUser, "password confirmation does not match", http.StatusBadRequest) return } if currentUser.Role != noodle.UserRoleAdmin && targetUsername != currentUser.Username { http.Error(w, "cannot change another user's password", http.StatusForbidden) return } if _, err := db.SetUserPassword(targetUsername, HashPassword(password)); err != nil { renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest) return } http.Redirect(w, req, "/users", http.StatusSeeOther) } } func HandleDeleteUser(auth *Auth, db *noodle.Database) func(w http.ResponseWriter, req *http.Request) { tmpl, err := template.ParseFS(assets.FS, "templates/*.html") if err != nil { log.Fatalf("Error parsing templates: %v", err) } return func(w http.ResponseWriter, req *http.Request) { currentUser, _ := auth.CurrentUser(req) if req.Method != http.MethodPost { http.Error(w, "", http.StatusMethodNotAllowed) return } if currentUser.Role != noodle.UserRoleAdmin { http.Error(w, "admin access required", http.StatusForbidden) return } if err := req.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } username := strings.TrimSpace(req.FormValue("username")) if username == "" { renderUsersPage(w, tmpl, db, currentUser, "username is required", http.StatusBadRequest) return } if _, err := db.DeleteUser(username); err != nil { renderUsersPage(w, tmpl, db, currentUser, err.Error(), http.StatusBadRequest) return } if username == currentUser.Username { auth.ClearSession(w) http.Redirect(w, req, "/login", http.StatusSeeOther) return } http.Redirect(w, req, "/users", http.StatusSeeOther) } } func HandleDelete(db *noodle.Database, pc *chan noodle.Noodle) func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(w, "", http.StatusMethodNotAllowed) return } if err := req.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } id := strings.TrimSpace(req.FormValue("id")) if id == "" { http.Error(w, "missing noodle id", http.StatusBadRequest) return } item, err := db.DeleteByID(id) if err != nil { http.Error(w, "noodle not found", http.StatusNotFound) return } item.IsUp = false *pc <- item log.Printf("Deleting noodle=%v", item) http.Redirect(w, req, "/", http.StatusTemporaryRedirect) } } func HandleAdd(db *noodle.Database, pc *chan noodle.Noodle, 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) } }