tr]:last:border-b-0",
48 | className
49 | )}
50 | {...props}
51 | />
52 | )
53 | }
54 |
55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
56 | return (
57 |
65 | )
66 | }
67 |
68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
69 | return (
70 | [role=checkbox]]:translate-y-[2px]",
74 | className
75 | )}
76 | {...props}
77 | />
78 | )
79 | }
80 |
81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
82 | return (
83 | [role=checkbox]]:translate-y-[2px]",
87 | className
88 | )}
89 | {...props}
90 | />
91 | )
92 | }
93 |
94 | function TableCaption({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"caption">) {
98 | return (
99 |
104 | )
105 | }
106 |
107 | export {
108 | Table,
109 | TableHeader,
110 | TableBody,
111 | TableFooter,
112 | TableHead,
113 | TableRow,
114 | TableCell,
115 | TableCaption,
116 | }
117 |
--------------------------------------------------------------------------------
/frontend/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "strconv"
7 | "strings"
8 | "time"
9 | )
10 |
11 | const (
12 | DefaultListenAddr = ":8080"
13 | DefaultStaticDir = "./out"
14 | DefaultDatabasePath = "./database/ufw-webui.db"
15 | DefaultJWTExpiry = "1d"
16 | CookieName = "auth_token"
17 | )
18 |
19 | type Config struct {
20 | ListenAddr string
21 | StaticDir string
22 | DatabasePath string
23 | AuthPassword string
24 | JWTSecret []byte
25 | JWTExpiresIn time.Duration
26 | AllowedOrigins []string
27 | }
28 |
29 | func Load() (*Config, error) {
30 | listenAddr := getEnvOrDefault("PORT", DefaultListenAddr)
31 | if listenAddr != "" && listenAddr[0] != ':' {
32 | listenAddr = ":" + listenAddr
33 | }
34 |
35 | staticDir := getEnvOrDefault("FRONTEND_DIST_DIR", DefaultStaticDir)
36 | dbPath := getEnvOrDefault("FRONTEND_DB_PATH", DefaultDatabasePath)
37 | authPassword := os.Getenv("AUTH_PASSWORD")
38 | jwtSecret := os.Getenv("JWT_SECRET")
39 | if jwtSecret == "" {
40 | return nil, fmt.Errorf("JWT_SECRET is not set")
41 | }
42 |
43 | expiresExpr := getEnvOrDefault("JWT_EXPIRATION", DefaultJWTExpiry)
44 | expiresIn, err := parseExpiry(expiresExpr)
45 | if err != nil {
46 | return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err)
47 | }
48 |
49 | allowedOrigins := parseOrigins(os.Getenv("FRONTEND_ALLOWED_ORIGINS"))
50 |
51 | return &Config{
52 | ListenAddr: listenAddr,
53 | StaticDir: staticDir,
54 | DatabasePath: dbPath,
55 | AuthPassword: authPassword,
56 | JWTSecret: []byte(jwtSecret),
57 | JWTExpiresIn: expiresIn,
58 | AllowedOrigins: allowedOrigins,
59 | }, nil
60 | }
61 |
62 | func getEnvOrDefault(key, fallback string) string {
63 | if value := os.Getenv(key); value != "" {
64 | return value
65 | }
66 | return fallback
67 | }
68 |
69 | func parseExpiry(expr string) (time.Duration, error) {
70 | if expr == "" {
71 | return 0, fmt.Errorf("empty expiration expression")
72 | }
73 |
74 | switch suffix := expr[len(expr)-1]; suffix {
75 | case 'd', 'h', 'm':
76 | base := expr[:len(expr)-1]
77 | value, err := strconv.ParseInt(base, 10, 64)
78 | if err != nil {
79 | return 0, fmt.Errorf("invalid expiration value %q: %w", expr, err)
80 | }
81 | multiplier := map[byte]time.Duration{'d': 24 * time.Hour, 'h': time.Hour, 'm': time.Minute}
82 | return time.Duration(value) * multiplier[suffix], nil
83 | default:
84 | value, err := strconv.ParseInt(expr, 10, 64)
85 | if err != nil {
86 | return 0, fmt.Errorf("invalid expiration value %q: %w", expr, err)
87 | }
88 | return time.Duration(value) * time.Second, nil
89 | }
90 | }
91 |
92 | func parseOrigins(raw string) []string {
93 | if raw == "" {
94 | return nil
95 | }
96 | parts := strings.Split(raw, ",")
97 | var cleaned []string
98 | for _, part := range parts {
99 | trimmed := strings.TrimSpace(part)
100 | if trimmed != "" {
101 | cleaned = append(cleaned, trimmed)
102 | }
103 | }
104 | return cleaned
105 | }
106 |
--------------------------------------------------------------------------------
/frontend/components/StatusControlCard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
4 | import { Button } from "@/components/ui/button";
5 | import { cn } from "@/lib/utils";
6 |
7 | interface StatusControlCardProps {
8 | ufwStatus: string | null;
9 | isSubmitting: boolean;
10 | onEnable: () => void;
11 | onDisable: () => void;
12 | className?: string;
13 | }
14 |
15 | export default function StatusControlCard({
16 | ufwStatus,
17 | isSubmitting,
18 | onEnable,
19 | onDisable,
20 | className,
21 | }: StatusControlCardProps) {
22 | const normalizedStatus = ufwStatus?.toLowerCase() ?? "unknown";
23 | const isActive = normalizedStatus === "active";
24 | const statusTone = isActive ? "bg-emerald-500/20 text-emerald-100" : "bg-rose-500/25 text-rose-100";
25 | const pulseTone = isActive ? "bg-emerald-300" : "bg-rose-300";
26 | const statusLabel = isSubmitting ? "Updating…" : ufwStatus ?? "Unknown";
27 |
28 | return (
29 |
35 |
36 |
37 | Firewall posture
38 |
39 | UFW Status & Control
40 |
41 |
42 |
43 |
44 |
45 |
Current state
46 |
47 |
48 | {statusLabel}
49 |
50 |
51 |
52 |
Actions reflect instantly on the selected backend.
53 |
Rule synchronisation runs after each update.
54 |
55 |
56 |
57 |
58 |
63 | Enable UFW
64 |
65 |
70 | Disable UFW
71 |
72 |
73 |
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/frontend/internal/app/server.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "net/http"
5 | "os"
6 | "path/filepath"
7 | "strings"
8 | "time"
9 |
10 | "github.com/gin-contrib/cors"
11 | "github.com/gin-gonic/gin"
12 |
13 | "ufwpanel/frontend/internal/config"
14 | "ufwpanel/frontend/internal/handlers"
15 | "ufwpanel/frontend/internal/middleware"
16 | "ufwpanel/frontend/internal/services/auth"
17 | "ufwpanel/frontend/internal/services/relay"
18 | "ufwpanel/frontend/internal/storage"
19 | )
20 |
21 | type Server struct {
22 | engine *gin.Engine
23 | repo *storage.BackendRepository
24 | }
25 |
26 | func NewServer(cfg *config.Config) (*Server, error) {
27 | repo, err := storage.NewBackendRepository(cfg.DatabasePath)
28 | if err != nil {
29 | return nil, err
30 | }
31 |
32 | authSvc := auth.NewService(cfg)
33 | relayClient := relay.NewClient(30 * time.Second)
34 |
35 | router := gin.New()
36 | router.Use(gin.Recovery(), gin.Logger())
37 |
38 | corsCfg := cors.Config{
39 | AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions},
40 | AllowHeaders: []string{"Content-Type", "X-API-KEY", "Authorization"},
41 | AllowCredentials: true,
42 | MaxAge: 12 * time.Hour,
43 | }
44 |
45 | allowAnyOrigin := len(cfg.AllowedOrigins) == 0
46 | if !allowAnyOrigin {
47 | for _, origin := range cfg.AllowedOrigins {
48 | if strings.TrimSpace(origin) == "*" {
49 | allowAnyOrigin = true
50 | break
51 | }
52 | }
53 | }
54 |
55 | if allowAnyOrigin {
56 | corsCfg.AllowOriginFunc = func(origin string) bool {
57 | return origin != ""
58 | }
59 | } else {
60 | corsCfg.AllowOrigins = cfg.AllowedOrigins
61 | }
62 |
63 | router.Use(cors.New(corsCfg))
64 |
65 | authHandler := handlers.NewAuthHandler(authSvc)
66 | backendHandler := handlers.NewBackendHandler(repo)
67 | firewallHandler := handlers.NewFirewallHandler(repo, relayClient)
68 |
69 | api := router.Group("/api")
70 | {
71 | public := api.Group("")
72 | authHandler.Register(public)
73 |
74 | secured := api.Group("")
75 | secured.Use(middleware.RequireAuth(authSvc))
76 | backendHandler.Register(secured)
77 | firewallHandler.Register(secured)
78 | }
79 |
80 | registerStatic(router, cfg.StaticDir)
81 |
82 | return &Server{engine: router, repo: repo}, nil
83 | }
84 |
85 | func (s *Server) Engine() *gin.Engine {
86 | return s.engine
87 | }
88 |
89 | func (s *Server) Close() error {
90 | if s.repo != nil {
91 | return s.repo.Close()
92 | }
93 | return nil
94 | }
95 |
96 | func registerStatic(router *gin.Engine, distDir string) {
97 | if distDir == "" {
98 | return
99 | }
100 |
101 | router.Static("/_next", filepath.Join(distDir, "_next"))
102 | router.Static("/static", filepath.Join(distDir, "static"))
103 | router.Static("/assets", filepath.Join(distDir, "assets"))
104 |
105 | router.GET("/", func(c *gin.Context) {
106 | serveIndex(c, distDir)
107 | })
108 |
109 | router.NoRoute(func(c *gin.Context) {
110 | requestPath := strings.TrimPrefix(c.Request.URL.Path, "/")
111 | if requestPath == "" {
112 | serveIndex(c, distDir)
113 | return
114 | }
115 |
116 | attempts := []string{
117 | requestPath,
118 | filepath.Join(requestPath, "index.html"),
119 | }
120 |
121 | for _, rel := range attempts {
122 | if serveFile(c, distDir, rel) {
123 | return
124 | }
125 | }
126 |
127 | serveIndex(c, distDir)
128 | })
129 | }
130 |
131 | func serveFile(c *gin.Context, distDir, relative string) bool {
132 | fullPath := filepath.Join(distDir, filepath.Clean(relative))
133 | info, err := os.Stat(fullPath)
134 | if err != nil || info.IsDir() {
135 | return false
136 | }
137 |
138 | c.File(fullPath)
139 | return true
140 | }
141 |
142 | func serveIndex(c *gin.Context, distDir string) {
143 | if !serveFile(c, distDir, "index.html") {
144 | c.Status(http.StatusNotFound)
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/frontend/components/DeleteBackendDialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | AlertDialog,
5 | AlertDialogAction,
6 | AlertDialogCancel,
7 | AlertDialogContent,
8 | AlertDialogDescription,
9 | AlertDialogFooter,
10 | AlertDialogHeader,
11 | AlertDialogTitle,
12 | } from "@/components/ui/alert-dialog";
13 | import { BackendConfig } from "@/lib/types";
14 | import { Loader2, ServerOff, XCircle } from "lucide-react";
15 |
16 | interface DeleteBackendDialogProps {
17 | backendToDelete: BackendConfig | null;
18 | onOpenChange: (open: boolean) => void;
19 | onConfirmDelete: (backend: BackendConfig) => Promise | void;
20 | isSubmitting?: boolean;
21 | }
22 |
23 | export default function DeleteBackendDialog({
24 | backendToDelete,
25 | onOpenChange,
26 | onConfirmDelete,
27 | isSubmitting = false,
28 | }: DeleteBackendDialogProps) {
29 | const handleConfirm = async () => {
30 | if (!backendToDelete) return;
31 | await onConfirmDelete(backendToDelete);
32 | onOpenChange(false);
33 | };
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | Remove backend node?
47 |
48 |
49 | The backend will be disconnected and no further automation will run against it.
50 |
51 | {backendToDelete && (
52 |
53 |
{backendToDelete.name}
54 |
{backendToDelete.url}
55 |
56 | )}
57 |
58 |
59 |
60 | onOpenChange(false)}
62 | disabled={isSubmitting}
63 | className="group flex h-11 items-center justify-center gap-2 rounded-xl border border-white/20 bg-white/10 px-6 text-sm font-semibold text-slate-100 transition hover:bg-white/15"
64 | >
65 |
66 | Cancel
67 |
68 |
73 | {isSubmitting ? : }
74 | Remove backend
75 |
76 |
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/frontend/internal/storage/sqlite.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "errors"
7 | "fmt"
8 | "os"
9 | "path/filepath"
10 |
11 | "ufwpanel/frontend/internal/models"
12 |
13 | _ "modernc.org/sqlite"
14 | )
15 |
16 | type BackendRepository struct {
17 | db *sql.DB
18 | }
19 |
20 | func NewBackendRepository(dbPath string) (*BackendRepository, error) {
21 | if err := ensureDir(dbPath); err != nil {
22 | return nil, fmt.Errorf("prepare db directory: %w", err)
23 | }
24 |
25 | dsn := fmt.Sprintf("file:%s?_fk=1", dbPath)
26 | db, err := sql.Open("sqlite", dsn)
27 | if err != nil {
28 | return nil, fmt.Errorf("open sqlite: %w", err)
29 | }
30 |
31 | if err := migrate(db); err != nil {
32 | _ = db.Close()
33 | return nil, err
34 | }
35 |
36 | return &BackendRepository{db: db}, nil
37 | }
38 |
39 | func ensureDir(dbPath string) error {
40 | dir := filepath.Dir(dbPath)
41 | if err := os.MkdirAll(dir, 0o755); err != nil {
42 | return err
43 | }
44 | return nil
45 | }
46 |
47 | func migrate(db *sql.DB) error {
48 | stmt := `
49 | CREATE TABLE IF NOT EXISTS backends (
50 | id TEXT PRIMARY KEY,
51 | name TEXT NOT NULL,
52 | url TEXT NOT NULL UNIQUE,
53 | apiKey TEXT NOT NULL,
54 | createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
55 | )`
56 | if _, err := db.Exec(stmt); err != nil {
57 | return fmt.Errorf("create table backends: %w", err)
58 | }
59 | return nil
60 | }
61 |
62 | func (r *BackendRepository) Close() error {
63 | if r == nil || r.db == nil {
64 | return nil
65 | }
66 | return r.db.Close()
67 | }
68 |
69 | func (r *BackendRepository) List(ctx context.Context) ([]models.Backend, error) {
70 | rows, err := r.db.QueryContext(ctx, `SELECT id, name, url FROM backends ORDER BY createdAt DESC`)
71 | if err != nil {
72 | return nil, fmt.Errorf("query backends: %w", err)
73 | }
74 | defer rows.Close()
75 |
76 | var backends []models.Backend
77 | for rows.Next() {
78 | var b models.Backend
79 | if err := rows.Scan(&b.ID, &b.Name, &b.URL); err != nil {
80 | return nil, fmt.Errorf("scan backend: %w", err)
81 | }
82 | backends = append(backends, b)
83 | }
84 | if err := rows.Err(); err != nil {
85 | return nil, fmt.Errorf("iterate backends: %w", err)
86 | }
87 | return backends, nil
88 | }
89 |
90 | func (r *BackendRepository) Get(ctx context.Context, id string) (*models.Backend, error) {
91 | var backend models.Backend
92 | err := r.db.QueryRowContext(ctx, `SELECT id, name, url, apiKey FROM backends WHERE id = ?`, id).
93 | Scan(&backend.ID, &backend.Name, &backend.URL, &backend.APIKey)
94 | if errors.Is(err, sql.ErrNoRows) {
95 | return nil, nil
96 | }
97 | if err != nil {
98 | return nil, fmt.Errorf("get backend %s: %w", id, err)
99 | }
100 | return &backend, nil
101 | }
102 |
103 | func (r *BackendRepository) Create(ctx context.Context, backend models.Backend) error {
104 | _, err := r.db.ExecContext(
105 | ctx,
106 | `INSERT INTO backends (id, name, url, apiKey) VALUES (?, ?, ?, ?)`,
107 | backend.ID, backend.Name, backend.URL, backend.APIKey,
108 | )
109 | if err != nil {
110 | return fmt.Errorf("insert backend: %w", err)
111 | }
112 | return nil
113 | }
114 |
115 | func (r *BackendRepository) Delete(ctx context.Context, id string) (bool, error) {
116 | res, err := r.db.ExecContext(ctx, `DELETE FROM backends WHERE id = ?`, id)
117 | if err != nil {
118 | return false, fmt.Errorf("delete backend %s: %w", id, err)
119 | }
120 | affected, err := res.RowsAffected()
121 | if err != nil {
122 | return false, fmt.Errorf("rows affected: %w", err)
123 | }
124 | return affected > 0, nil
125 | }
126 |
127 | func (r *BackendRepository) Update(ctx context.Context, backend models.Backend) error {
128 | _, err := r.db.ExecContext(
129 | ctx,
130 | `UPDATE backends SET name = ?, url = ?, apiKey = ? WHERE id = ?`,
131 | backend.Name, backend.URL, backend.APIKey, backend.ID,
132 | )
133 | if err != nil {
134 | return fmt.Errorf("update backend %s: %w", backend.ID, err)
135 | }
136 | return nil
137 | }
138 |
--------------------------------------------------------------------------------
/frontend/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Dialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | )
47 | }
48 |
49 | type DialogContentProps = React.ComponentProps
50 |
51 | function DialogContent({
52 | className,
53 | children,
54 | ...props
55 | }: DialogContentProps) {
56 | return (
57 |
58 |
59 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | )
75 | }
76 |
77 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
78 | return (
79 |
84 | )
85 | }
86 |
87 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
88 | return (
89 |
97 | )
98 | }
99 |
100 | function DialogTitle({
101 | className,
102 | ...props
103 | }: React.ComponentProps) {
104 | return (
105 |
110 | )
111 | }
112 |
113 | function DialogDescription({
114 | className,
115 | ...props
116 | }: React.ComponentProps) {
117 | return (
118 |
123 | )
124 | }
125 |
126 | export {
127 | Dialog,
128 | DialogClose,
129 | DialogContent,
130 | DialogDescription,
131 | DialogFooter,
132 | DialogHeader,
133 | DialogOverlay,
134 | DialogPortal,
135 | DialogTitle,
136 | DialogTrigger,
137 | }
138 |
--------------------------------------------------------------------------------
/.github/workflows/backend-ci.yml:
--------------------------------------------------------------------------------
1 | name: Backend CI and Release
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | tags: [ 'v*' ]
7 | paths:
8 | - 'backend/**'
9 | pull_request:
10 | branches: [ "main" ]
11 | paths:
12 | - 'backend/**'
13 |
14 | jobs:
15 | build:
16 | runs-on: ubuntu-latest
17 | outputs:
18 | release_tag: ${{ steps.get_tag.outputs.tag }}
19 | project_name: ${{ steps.project_vars.outputs.name }}
20 | steps:
21 | - uses: actions/checkout@v4
22 |
23 | - name: Set up Go
24 | uses: actions/setup-go@v5
25 | with:
26 | go-version: '1.24.2'
27 |
28 | - name: Set project name
29 | id: project_vars
30 | run: echo "name=ufw-panel-backend" >> $GITHUB_OUTPUT
31 | shell: bash
32 |
33 | - name: Build for linux/amd64
34 | run: |
35 | GOOS=linux GOARCH=amd64 go build \
36 | -trimpath \
37 | -buildvcs=false \
38 | -ldflags="-s -w" \
39 | -o ${{ steps.project_vars.outputs.name }}-linux-amd64 \
40 | .
41 | working-directory: ./backend
42 |
43 | - name: Build for linux/arm64
44 | run: |
45 | GOOS=linux GOARCH=arm64 go build \
46 | -trimpath \
47 | -buildvcs=false \
48 | -ldflags="-s -w" \
49 | -o ${{ steps.project_vars.outputs.name }}-linux-arm64 \
50 | .
51 | working-directory: ./backend
52 |
53 | - name: Test
54 | run: go test -v ./...
55 | working-directory: ./backend
56 |
57 | # 下面两步:只在 tag push 时安装并使用 upx 压缩
58 | - name: Install upx (release only)
59 | if: startsWith(github.ref, 'refs/tags/')
60 | run: |
61 | sudo apt-get update
62 | sudo apt-get install -y upx
63 |
64 | - name: Compress binaries with upx (release only)
65 | if: startsWith(github.ref, 'refs/tags/')
66 | run: |
67 | echo "Before upx:"
68 | ls -lh ${{ steps.project_vars.outputs.name }}-linux-amd64 ${{ steps.project_vars.outputs.name }}-linux-arm64 || true
69 |
70 | upx --best --lzma --ultra-brute ${{ steps.project_vars.outputs.name }}-linux-amd64
71 | upx --best --lzma --ultra-brute ${{ steps.project_vars.outputs.name }}-linux-arm64
72 |
73 | echo "After upx:"
74 | ls -lh ${{ steps.project_vars.outputs.name }}-linux-amd64 ${{ steps.project_vars.outputs.name }}-linux-arm64
75 | working-directory: ./backend
76 |
77 | - name: Upload amd64 binary
78 | uses: actions/upload-artifact@v4
79 | with:
80 | name: ${{ steps.project_vars.outputs.name }}-linux-amd64
81 | path: backend/${{ steps.project_vars.outputs.name }}-linux-amd64
82 |
83 | - name: Upload arm64 binary
84 | uses: actions/upload-artifact@v4
85 | with:
86 | name: ${{ steps.project_vars.outputs.name }}-linux-arm64
87 | path: backend/${{ steps.project_vars.outputs.name }}-linux-arm64
88 |
89 | - name: Get tag name
90 | id: get_tag
91 | if: startsWith(github.ref, 'refs/tags/')
92 | run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
93 |
94 | release:
95 | needs: build
96 | runs-on: ubuntu-latest
97 | if: startsWith(github.ref, 'refs/tags/') # Only run on tag pushes
98 | permissions:
99 | contents: write # Required to create releases
100 | steps:
101 | - name: Download amd64 binary
102 | uses: actions/download-artifact@v4
103 | with:
104 | name: ${{ needs.build.outputs.project_name }}-linux-amd64
105 | path: ./release_artifacts
106 |
107 | - name: Download arm64 binary
108 | uses: actions/download-artifact@v4
109 | with:
110 | name: ${{ needs.build.outputs.project_name }}-linux-arm64
111 | path: ./release_artifacts
112 |
113 | - name: List downloaded artifacts
114 | run: ls -R ./release_artifacts
115 |
116 | - name: Create Release
117 | id: create_release
118 | uses: softprops/action-gh-release@v2
119 | with:
120 | tag_name: ${{ needs.build.outputs.release_tag }}
121 | name: Release ${{ needs.build.outputs.release_tag }}
122 | body: |
123 | Automated release for ${{ needs.build.outputs.release_tag }}
124 | Contains binaries for linux/amd64 and linux/arm64.
125 | draft: false
126 | prerelease: false
127 | files: |
128 | ./release_artifacts/${{ needs.build.outputs.project_name }}-linux-amd64
129 | ./release_artifacts/${{ needs.build.outputs.project_name }}-linux-arm64
130 |
--------------------------------------------------------------------------------
/frontend/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | function AlertDialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function AlertDialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function AlertDialogPortal({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
28 | )
29 | }
30 |
31 | function AlertDialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function AlertDialogContent({
48 | className,
49 | ...props
50 | }: React.ComponentProps) {
51 | return (
52 |
53 |
54 |
62 |
63 | )
64 | }
65 |
66 | function AlertDialogHeader({
67 | className,
68 | ...props
69 | }: React.ComponentProps<"div">) {
70 | return (
71 |
76 | )
77 | }
78 |
79 | function AlertDialogFooter({
80 | className,
81 | ...props
82 | }: React.ComponentProps<"div">) {
83 | return (
84 |
92 | )
93 | }
94 |
95 | function AlertDialogTitle({
96 | className,
97 | ...props
98 | }: React.ComponentProps) {
99 | return (
100 |
105 | )
106 | }
107 |
108 | function AlertDialogDescription({
109 | className,
110 | ...props
111 | }: React.ComponentProps) {
112 | return (
113 |
118 | )
119 | }
120 |
121 | function AlertDialogAction({
122 | className,
123 | ...props
124 | }: React.ComponentProps) {
125 | return (
126 |
130 | )
131 | }
132 |
133 | function AlertDialogCancel({
134 | className,
135 | ...props
136 | }: React.ComponentProps) {
137 | return (
138 |
142 | )
143 | }
144 |
145 | export {
146 | AlertDialog,
147 | AlertDialogPortal,
148 | AlertDialogOverlay,
149 | AlertDialogTrigger,
150 | AlertDialogContent,
151 | AlertDialogHeader,
152 | AlertDialogFooter,
153 | AlertDialogTitle,
154 | AlertDialogDescription,
155 | AlertDialogAction,
156 | AlertDialogCancel,
157 | }
158 |
--------------------------------------------------------------------------------
/frontend/components/DeleteRuleDialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | AlertDialog,
5 | AlertDialogAction,
6 | AlertDialogCancel,
7 | AlertDialogContent,
8 | AlertDialogDescription,
9 | AlertDialogFooter,
10 | AlertDialogHeader,
11 | AlertDialogTitle,
12 | } from "@/components/ui/alert-dialog";
13 | import { Loader2, ShieldAlert, XCircle } from "lucide-react";
14 | import type { ParsedRule } from "./RulesTableCard";
15 |
16 | interface DeleteRuleDialogProps {
17 | ruleToDelete: ParsedRule | null;
18 | onOpenChange: (open: boolean) => void;
19 | onConfirmDelete: (ruleNumber: string) => void;
20 | isSubmitting: boolean;
21 | }
22 |
23 | export default function DeleteRuleDialog({
24 | ruleToDelete,
25 | onOpenChange,
26 | onConfirmDelete,
27 | isSubmitting,
28 | }: DeleteRuleDialogProps) {
29 | const isOpen = !!ruleToDelete;
30 |
31 | const handleConfirm = () => {
32 | if (ruleToDelete) {
33 | onConfirmDelete(ruleToDelete.number);
34 | }
35 | };
36 |
37 | return (
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | Remove firewall rule?
49 |
50 |
51 | This action cannot be undone. The selected rule will be removed from the backend firewall immediately.
52 |
53 | {ruleToDelete && (
54 |
55 |
56 | Rule #{ruleToDelete.number}
57 |
58 | {ruleToDelete.action}
59 |
60 |
61 |
62 |
To: {ruleToDelete.to}
63 |
From: {ruleToDelete.from}
64 | {ruleToDelete.details && (
65 |
66 | Details: {ruleToDelete.details}
67 |
68 | )}
69 |
70 |
71 | )}
72 |
73 |
74 |
75 |
79 |
80 | Cancel
81 |
82 |
87 | {isSubmitting ? : }
88 | Delete rule
89 |
90 |
91 |
92 |
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/frontend/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --font-sans: var(--font-geist-sans);
10 | --font-mono: var(--font-geist-mono);
11 | --color-sidebar-ring: var(--sidebar-ring);
12 | --color-sidebar-border: var(--sidebar-border);
13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14 | --color-sidebar-accent: var(--sidebar-accent);
15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16 | --color-sidebar-primary: var(--sidebar-primary);
17 | --color-sidebar-foreground: var(--sidebar-foreground);
18 | --color-sidebar: var(--sidebar);
19 | --color-chart-5: var(--chart-5);
20 | --color-chart-4: var(--chart-4);
21 | --color-chart-3: var(--chart-3);
22 | --color-chart-2: var(--chart-2);
23 | --color-chart-1: var(--chart-1);
24 | --color-ring: var(--ring);
25 | --color-input: var(--input);
26 | --color-border: var(--border);
27 | --color-destructive: var(--destructive);
28 | --color-accent-foreground: var(--accent-foreground);
29 | --color-accent: var(--accent);
30 | --color-muted-foreground: var(--muted-foreground);
31 | --color-muted: var(--muted);
32 | --color-secondary-foreground: var(--secondary-foreground);
33 | --color-secondary: var(--secondary);
34 | --color-primary-foreground: var(--primary-foreground);
35 | --color-primary: var(--primary);
36 | --color-popover-foreground: var(--popover-foreground);
37 | --color-popover: var(--popover);
38 | --color-card-foreground: var(--card-foreground);
39 | --color-card: var(--card);
40 | --radius-sm: calc(var(--radius) - 4px);
41 | --radius-md: calc(var(--radius) - 2px);
42 | --radius-lg: var(--radius);
43 | --radius-xl: calc(var(--radius) + 4px);
44 | }
45 |
46 | :root {
47 | --radius: 0.625rem;
48 | --background: oklch(1 0 0);
49 | --foreground: oklch(0.141 0.005 285.823);
50 | --card: oklch(1 0 0);
51 | --card-foreground: oklch(0.141 0.005 285.823);
52 | --popover: oklch(1 0 0);
53 | --popover-foreground: oklch(0.141 0.005 285.823);
54 | --primary: oklch(0.21 0.006 285.885);
55 | --primary-foreground: oklch(0.985 0 0);
56 | --secondary: oklch(0.967 0.001 286.375);
57 | --secondary-foreground: oklch(0.21 0.006 285.885);
58 | --muted: oklch(0.967 0.001 286.375);
59 | --muted-foreground: oklch(0.552 0.016 285.938);
60 | --accent: oklch(0.967 0.001 286.375);
61 | --accent-foreground: oklch(0.21 0.006 285.885);
62 | --destructive: oklch(0.577 0.245 27.325);
63 | --border: oklch(0.92 0.004 286.32);
64 | --input: oklch(0.92 0.004 286.32);
65 | --ring: oklch(0.705 0.015 286.067);
66 | --chart-1: oklch(0.646 0.222 41.116);
67 | --chart-2: oklch(0.6 0.118 184.704);
68 | --chart-3: oklch(0.398 0.07 227.392);
69 | --chart-4: oklch(0.828 0.189 84.429);
70 | --chart-5: oklch(0.769 0.188 70.08);
71 | --sidebar: oklch(0.985 0 0);
72 | --sidebar-foreground: oklch(0.141 0.005 285.823);
73 | --sidebar-primary: oklch(0.21 0.006 285.885);
74 | --sidebar-primary-foreground: oklch(0.985 0 0);
75 | --sidebar-accent: oklch(0.967 0.001 286.375);
76 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
77 | --sidebar-border: oklch(0.92 0.004 286.32);
78 | --sidebar-ring: oklch(0.705 0.015 286.067);
79 | }
80 |
81 | .dark {
82 | --background: oklch(0.141 0.005 285.823);
83 | --foreground: oklch(0.985 0 0);
84 | --card: oklch(0.21 0.006 285.885);
85 | --card-foreground: oklch(0.985 0 0);
86 | --popover: oklch(0.21 0.006 285.885);
87 | --popover-foreground: oklch(0.985 0 0);
88 | --primary: oklch(0.92 0.004 286.32);
89 | --primary-foreground: oklch(0.21 0.006 285.885);
90 | --secondary: oklch(0.274 0.006 286.033);
91 | --secondary-foreground: oklch(0.985 0 0);
92 | --muted: oklch(0.274 0.006 286.033);
93 | --muted-foreground: oklch(0.705 0.015 286.067);
94 | --accent: oklch(0.274 0.006 286.033);
95 | --accent-foreground: oklch(0.985 0 0);
96 | --destructive: oklch(0.704 0.191 22.216);
97 | --border: oklch(1 0 0 / 10%);
98 | --input: oklch(1 0 0 / 15%);
99 | --ring: oklch(0.552 0.016 285.938);
100 | --chart-1: oklch(0.488 0.243 264.376);
101 | --chart-2: oklch(0.696 0.17 162.48);
102 | --chart-3: oklch(0.769 0.188 70.08);
103 | --chart-4: oklch(0.627 0.265 303.9);
104 | --chart-5: oklch(0.645 0.246 16.439);
105 | --sidebar: oklch(0.21 0.006 285.885);
106 | --sidebar-foreground: oklch(0.985 0 0);
107 | --sidebar-primary: oklch(0.488 0.243 264.376);
108 | --sidebar-primary-foreground: oklch(0.985 0 0);
109 | --sidebar-accent: oklch(0.274 0.006 286.033);
110 | --sidebar-accent-foreground: oklch(0.985 0 0);
111 | --sidebar-border: oklch(1 0 0 / 10%);
112 | --sidebar-ring: oklch(0.552 0.016 285.938);
113 | }
114 |
115 | @layer base {
116 | * {
117 | @apply border-border outline-ring/50;
118 | }
119 | body {
120 | @apply bg-background text-foreground;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # UFW Panel - Web UI for Uncomplicated Firewall
2 |
3 | UFW Panel provides a user-friendly web interface to manage UFW (Uncomplicated Firewall) on your Linux server. It consists of a Go-based backend API and a Next.js frontend.
4 |
5 | ## ✨ Features
6 |
7 | * **Firewall Status:** View whether UFW is active or inactive.
8 | * **Toggle Firewall:** Easily enable or disable UFW.
9 | * **Rule Management:**
10 | * View all current UFW rules with their numbers.
11 | * Add new rules:
12 | * Allow or deny traffic on specific ports.
13 | * Allow or deny traffic from specific IP addresses (optionally for specific ports).
14 | * Delete existing rules by their number.
15 | * **Secure Access:** Password-protected interface to prevent unauthorized changes.
16 | * **Responsive Design:** Manage your firewall from desktop or mobile devices.
17 |
18 | ## 😎 How to use
19 |
20 | The installation process involves setting up the backend service and then deploying the frontend container.
21 |
22 | ### 1. Install Backend
23 |
24 | The backend is a Go application that interacts with UFW and provides an API for the frontend.
25 |
26 | ```bash
27 | # Download the deployment script
28 | wget https://raw.githubusercontent.com/Gouryella/UFW-Panel/main/deploy_backend.sh
29 |
30 | # Make the script executable
31 | chmod +x deploy_backend.sh
32 |
33 | # Run the script with sudo (it requires root privileges)
34 | sudo bash deploy_backend.sh
35 | ```
36 |
37 | During the execution, the script will:
38 | * Detect your server's architecture (amd64 or arm64).
39 | * Fetch the latest backend release from GitHub.
40 | * Prompt you to enter:
41 | * **Port for the backend service:** (Default: `8080`) This is the port the backend API will listen on.
42 | * **Password for API access:** This password will be used by the frontend to authenticate with the backend. Remember this password, as you'll need it for the frontend setup.
43 | * **CORS allowed origin:** This should be the URL where your frontend will be accessible (e.g., `http://your_server_ip:30737` or `http://localhost:3000` if running locally).
44 | * Install the backend executable to `/usr/local/bin`.
45 | * Create an environment file at `/usr/local/bin/.env_ufw_backend` with your settings.
46 | * Set up a systemd service named `ufw-panel-backend` to manage the backend process.
47 | * Start and enable the service.
48 |
49 | You can manage the backend service using standard systemd commands:
50 | * `sudo systemctl status ufw-panel-backend`
51 | * `sudo systemctl stop ufw-panel-backend`
52 | * `sudo systemctl start ufw-panel-backend`
53 | * `sudo systemctl restart ufw-panel-backend`
54 | * `sudo journalctl -u ufw-panel-backend -f` (to view logs)
55 |
56 | ### 2. Install Frontend
57 |
58 | The frontend is a Next.js application deployed using Docker.
59 |
60 | ```bash
61 | # Download the sample environment file
62 | wget https://raw.githubusercontent.com/Gouryella/UFW-Panel/main/.env.sample
63 |
64 | # Copy it to .env
65 | cp .env.sample .env
66 |
67 | # Download the Docker Compose file
68 | wget https://raw.githubusercontent.com/Gouryella/UFW-Panel/main/docker-compose.yml
69 | ```
70 |
71 | Next, **edit the `.env` file** with your specific configuration:
72 |
73 | ```env
74 | JWT_SECRET="your_auth_secret"
75 | AUTH_PASSWORD="your_auth_token"
76 | JWT_EXPIRATION=1d
77 | ```
78 |
79 | * `JWT_SECRET`: Set this to a long, random, and strong secret string. This is used to sign authentication tokens for the web UI. You can generate one using `openssl rand -hex 32`.
80 | * `AUTH_PASSWORD`: **Important!** This **must** be the same password you set during the backend installation when prompted for "Password for API access". This password is used by the frontend to log in to the backend API.
81 | * `JWT_EXPIRATION`: Defines how long the login session for the web UI remains valid (e.g., `1d` for one day, `7d` for seven days).
82 |
83 | After configuring the `.env` file, deploy the frontend using Docker Compose:
84 |
85 | ```bash
86 | docker compose up -d
87 | ```
88 |
89 | This command will:
90 | * Pull the latest `gouryella/ufw-panel:latest` Docker image for the frontend.
91 | * Start a container named `ufw-panel-frontend`.
92 | * Map port `30737` on your host to port `3000` inside the container.
93 | * Use the `.env` file for environment variables.
94 | * Mount a volume `ufw_db_data` for persistent data (if any is used by the frontend for its own settings, separate from UFW rules).
95 | * Set the container to restart automatically unless stopped.
96 |
97 | ### 3. Accessing the UFW Panel
98 |
99 | Once both the backend and frontend are running, you can access the UFW Panel web interface in your browser.
100 |
101 | Navigate to: `http://:30737`
102 |
103 | Replace `` with the actual IP address of your server. You will be prompted to log in using the `AUTH_PASSWORD` you configured.
104 |
105 | ## 📄 License
106 |
107 | This project is open-source. Please refer to the license file if one is included, or assume standard open-source licensing practices.
108 |
--------------------------------------------------------------------------------
/frontend/internal/handlers/backends.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 | "strings"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/google/uuid"
10 |
11 | "ufwpanel/frontend/internal/models"
12 | "ufwpanel/frontend/internal/storage"
13 | )
14 |
15 | type BackendHandler struct {
16 | repo *storage.BackendRepository
17 | }
18 |
19 | func NewBackendHandler(repo *storage.BackendRepository) *BackendHandler {
20 | return &BackendHandler{repo: repo}
21 | }
22 |
23 | func (h *BackendHandler) Register(rg *gin.RouterGroup) {
24 | rg.GET("/backends", h.list)
25 | rg.POST("/backends", h.create)
26 | rg.PUT("/backends/:id", h.update)
27 | rg.DELETE("/backends", h.remove)
28 | }
29 |
30 | func (h *BackendHandler) list(c *gin.Context) {
31 | ctx := c.Request.Context()
32 | backends, err := h.repo.List(ctx)
33 | if err != nil {
34 | writeError(c, http.StatusInternalServerError, "Failed to fetch backends.", err.Error())
35 | return
36 | }
37 | c.JSON(http.StatusOK, backends)
38 | }
39 |
40 | func (h *BackendHandler) create(c *gin.Context) {
41 | type request struct {
42 | Name string `json:"name"`
43 | URL string `json:"url"`
44 | APIKey string `json:"apiKey"`
45 | }
46 |
47 | var body request
48 | if err := c.ShouldBindJSON(&body); err != nil {
49 | writeError(c, http.StatusBadRequest, "Invalid payload.", nil)
50 | return
51 | }
52 |
53 | body.Name = strings.TrimSpace(body.Name)
54 | body.URL = strings.TrimSpace(body.URL)
55 | body.APIKey = strings.TrimSpace(body.APIKey)
56 |
57 | if body.Name == "" || body.URL == "" || body.APIKey == "" {
58 | writeError(c, http.StatusBadRequest, "Missing required fields: name, url, apiKey.", nil)
59 | return
60 | }
61 |
62 | if !isValidURL(body.URL) {
63 | writeError(c, http.StatusBadRequest, "Invalid URL format.", nil)
64 | return
65 | }
66 |
67 | backend := models.Backend{
68 | ID: uuid.NewString(),
69 | Name: body.Name,
70 | URL: body.URL,
71 | APIKey: body.APIKey,
72 | }
73 |
74 | ctx := c.Request.Context()
75 | if err := h.repo.Create(ctx, backend); err != nil {
76 | if isUniqueViolation(err) {
77 | writeError(c, http.StatusConflict, "A backend with this URL already exists.", nil)
78 | return
79 | }
80 | writeError(c, http.StatusInternalServerError, "Failed to add backend.", err.Error())
81 | return
82 | }
83 |
84 | c.JSON(http.StatusCreated, backend)
85 | }
86 |
87 | func (h *BackendHandler) remove(c *gin.Context) {
88 | id := c.Query("id")
89 | if id == "" {
90 | writeError(c, http.StatusBadRequest, "Missing backend ID query parameter.", nil)
91 | return
92 | }
93 |
94 | ctx := c.Request.Context()
95 | deleted, err := h.repo.Delete(ctx, id)
96 | if err != nil {
97 | writeError(c, http.StatusInternalServerError, "Failed to remove backend.", err.Error())
98 | return
99 | }
100 | if !deleted {
101 | writeError(c, http.StatusNotFound, "Backend configuration not found.", nil)
102 | return
103 | }
104 |
105 | c.JSON(http.StatusOK, gin.H{"message": "Backend removed successfully"})
106 | }
107 |
108 | func (h *BackendHandler) update(c *gin.Context) {
109 | id := strings.TrimSpace(c.Param("id"))
110 | if id == "" {
111 | writeError(c, http.StatusBadRequest, "Missing backend ID.", nil)
112 | return
113 | }
114 |
115 | type request struct {
116 | Name string `json:"name"`
117 | URL string `json:"url"`
118 | APIKey *string `json:"apiKey"`
119 | }
120 |
121 | var body request
122 | if err := c.ShouldBindJSON(&body); err != nil {
123 | writeError(c, http.StatusBadRequest, "Invalid payload.", nil)
124 | return
125 | }
126 |
127 | body.Name = strings.TrimSpace(body.Name)
128 | body.URL = strings.TrimSpace(body.URL)
129 |
130 | if body.Name == "" || body.URL == "" {
131 | writeError(c, http.StatusBadRequest, "Missing required fields: name, url.", nil)
132 | return
133 | }
134 | if !isValidURL(body.URL) {
135 | writeError(c, http.StatusBadRequest, "Invalid URL format.", nil)
136 | return
137 | }
138 |
139 | ctx := c.Request.Context()
140 | backend, err := h.repo.Get(ctx, id)
141 | if err != nil {
142 | writeError(c, http.StatusInternalServerError, "Failed to fetch backend.", err.Error())
143 | return
144 | }
145 | if backend == nil {
146 | writeError(c, http.StatusNotFound, "Backend configuration not found.", nil)
147 | return
148 | }
149 |
150 | backend.Name = body.Name
151 | backend.URL = body.URL
152 |
153 | if body.APIKey != nil {
154 | apiKey := strings.TrimSpace(*body.APIKey)
155 | if apiKey == "" {
156 | writeError(c, http.StatusBadRequest, "API Key cannot be empty when provided.", nil)
157 | return
158 | }
159 | backend.APIKey = apiKey
160 | }
161 |
162 | if err := h.repo.Update(ctx, *backend); err != nil {
163 | if isUniqueViolation(err) {
164 | writeError(c, http.StatusConflict, "A backend with this URL already exists.", nil)
165 | return
166 | }
167 | writeError(c, http.StatusInternalServerError, "Failed to update backend.", err.Error())
168 | return
169 | }
170 |
171 | c.JSON(http.StatusOK, gin.H{
172 | "id": backend.ID,
173 | "name": backend.Name,
174 | "url": backend.URL,
175 | })
176 | }
177 |
178 | func isValidURL(raw string) bool {
179 | u, err := url.ParseRequestURI(raw)
180 | if err != nil {
181 | return false
182 | }
183 | return u.Scheme != "" && u.Host != ""
184 | }
185 |
186 | func isUniqueViolation(err error) bool {
187 | if err == nil {
188 | return false
189 | }
190 | return strings.Contains(err.Error(), "UNIQUE constraint failed")
191 | }
192 |
--------------------------------------------------------------------------------
/frontend/components/BackendStatus.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect, useMemo, useCallback } from "react";
4 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
5 | import { Button } from "@/components/ui/button";
6 | import { RefreshCcw } from "lucide-react";
7 | import { resolveApiUrl } from "@/lib/api";
8 |
9 | interface Backend {
10 | id: string;
11 | name: string;
12 | url: string;
13 | apiKey?: string;
14 | }
15 |
16 | export default function BackendStatusCards() {
17 | const [onlineCount, setOnlineCount] = useState(null);
18 | const [totalCount, setTotalCount] = useState(0);
19 | const [loading, setLoading] = useState(false);
20 | const [initialLoading, setInitialLoading] = useState(true);
21 |
22 | const fetchOnce = useCallback(async () => {
23 | setLoading(true);
24 | try {
25 | const res = await fetch(resolveApiUrl("/api/backends"), {
26 | credentials: "include",
27 | });
28 | if (!res.ok) throw new Error("failed");
29 | const backends: Backend[] = await res.json();
30 | setTotalCount(backends.length || 0);
31 |
32 | const results = await Promise.all(
33 | backends.map(async (b) => {
34 | try {
35 | const r = await fetch(resolveApiUrl(`/api/status?backendId=${b.id}`), {
36 | credentials: "include",
37 | });
38 | return r.ok;
39 | } catch {
40 | return false;
41 | }
42 | })
43 | );
44 | setOnlineCount(results.filter(Boolean).length);
45 | } catch {
46 | setOnlineCount(null);
47 | } finally {
48 | setLoading(false);
49 | setInitialLoading(false);
50 | }
51 | }, []);
52 |
53 | useEffect(() => {
54 | fetchOnce();
55 | const t = setInterval(fetchOnce, 30000);
56 | return () => clearInterval(t);
57 | }, [fetchOnce]);
58 |
59 | const availability = useMemo(() => {
60 | if (onlineCount === null || !totalCount) return "—";
61 | return `${Math.round((onlineCount / totalCount) * 100)}%`;
62 | }, [onlineCount, totalCount]);
63 |
64 | const availabilityValue = useMemo(() => {
65 | if (onlineCount === null || !totalCount) return 0;
66 | return Math.min(100, Math.round((onlineCount / totalCount) * 100));
67 | }, [onlineCount, totalCount]);
68 |
69 | return (
70 |
71 |
72 |
73 |
74 |
75 | Registered nodes
76 |
77 | Inventory
78 |
79 |
80 |
81 |
82 |
83 | {initialLoading ? "—" : totalCount}
84 |
85 |
86 | Keep at least one backend online for uninterrupted firewall automation.
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | Online nodes
97 |
98 | Availability
99 |
100 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
118 | {initialLoading ? "—" : onlineCount ?? "—"}
119 |
120 |
{availability}
121 |
122 |
128 |
129 | Heartbeats refresh automatically every 30 seconds.
130 |
131 |
132 |
133 |
134 |
135 | );
136 | }
137 |
--------------------------------------------------------------------------------
/frontend/internal/handlers/firewall.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "strings"
9 |
10 | "github.com/gin-gonic/gin"
11 |
12 | "ufwpanel/frontend/internal/models"
13 | "ufwpanel/frontend/internal/services/relay"
14 | "ufwpanel/frontend/internal/storage"
15 | )
16 |
17 | type FirewallHandler struct {
18 | repo *storage.BackendRepository
19 | relay *relay.Client
20 | }
21 |
22 | func NewFirewallHandler(repo *storage.BackendRepository, relayClient *relay.Client) *FirewallHandler {
23 | return &FirewallHandler{repo: repo, relay: relayClient}
24 | }
25 |
26 | func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
27 | rg.GET("/status", h.status)
28 | rg.POST("/enable", h.enable)
29 | rg.POST("/disable", h.disable)
30 | rg.POST("/rules/allow", h.allowRule)
31 | rg.POST("/rules/deny", h.denyRule)
32 | rg.POST("/rules/allow/ip", h.allowIP)
33 | rg.POST("/rules/deny/ip", h.denyIP)
34 | rg.DELETE("/rules/delete/:ruleNumber", h.deleteRule)
35 | }
36 |
37 | func (h *FirewallHandler) status(c *gin.Context) {
38 | backend, ok := h.lookupBackend(c)
39 | if !ok {
40 | return
41 | }
42 |
43 | resp, err := h.relay.Forward(c.Request.Context(), backend, http.MethodGet, "/status", nil)
44 | if err != nil {
45 | writeError(c, http.StatusInternalServerError, "Failed to fetch status from backend.", err.Error())
46 | return
47 | }
48 | defer resp.Body.Close()
49 |
50 | payloadAny, empty, err := decodeJSON(resp.Body)
51 | if err != nil {
52 | writeError(c, http.StatusInternalServerError, "Failed to decode backend response.", err.Error())
53 | return
54 | }
55 |
56 | if empty {
57 | payloadAny = map[string]any{}
58 | }
59 |
60 | payload, ok := payloadAny.(map[string]any)
61 | if !ok {
62 | writeError(c, http.StatusInternalServerError, "Unexpected backend payload shape.", nil)
63 | return
64 | }
65 |
66 | if resp.StatusCode < 200 || resp.StatusCode >= 300 {
67 | writeError(c, resp.StatusCode, "Failed to fetch status from backend", payload)
68 | return
69 | }
70 |
71 | status, ok := payload["status"]
72 | if !ok {
73 | status = "unknown"
74 | }
75 | rules, ok := payload["rules"]
76 | if !ok {
77 | rules = []any{}
78 | }
79 |
80 | c.JSON(http.StatusOK, gin.H{
81 | "status": status,
82 | "rules": rules,
83 | })
84 | }
85 |
86 | func (h *FirewallHandler) enable(c *gin.Context) {
87 | h.forwardWithoutBody(c, http.MethodPost, "/enable", "Failed to enable UFW")
88 | }
89 |
90 | func (h *FirewallHandler) disable(c *gin.Context) {
91 | h.forwardWithoutBody(c, http.MethodPost, "/disable", "Failed to disable UFW")
92 | }
93 |
94 | func (h *FirewallHandler) allowRule(c *gin.Context) {
95 | h.forwardWithBody(c, http.MethodPost, "/rules/allow", "Failed to add allow rule", "rule")
96 | }
97 |
98 | func (h *FirewallHandler) denyRule(c *gin.Context) {
99 | h.forwardWithBody(c, http.MethodPost, "/rules/deny", "Failed to add deny rule", "rule")
100 | }
101 |
102 | func (h *FirewallHandler) allowIP(c *gin.Context) {
103 | h.forwardWithBody(c, http.MethodPost, "/rules/allow/ip", "Failed to add allow IP rule", "ip_address")
104 | }
105 |
106 | func (h *FirewallHandler) denyIP(c *gin.Context) {
107 | h.forwardWithBody(c, http.MethodPost, "/rules/deny/ip", "Failed to add deny IP rule", "ip_address")
108 | }
109 |
110 | func (h *FirewallHandler) deleteRule(c *gin.Context) {
111 | ruleNumber := c.Param("ruleNumber")
112 | if strings.TrimSpace(ruleNumber) == "" {
113 | writeError(c, http.StatusBadRequest, "Missing rule number.", nil)
114 | return
115 | }
116 | h.forwardWithoutBody(c, http.MethodDelete, fmt.Sprintf("/rules/delete/%s", ruleNumber), "Failed to delete rule")
117 | }
118 |
119 | func (h *FirewallHandler) forwardWithoutBody(c *gin.Context, method, path, errMsg string) {
120 | backend, ok := h.lookupBackend(c)
121 | if !ok {
122 | return
123 | }
124 |
125 | resp, err := h.relay.Forward(c.Request.Context(), backend, method, path, nil)
126 | if err != nil {
127 | writeError(c, http.StatusInternalServerError, errMsg, err.Error())
128 | return
129 | }
130 | defer resp.Body.Close()
131 |
132 | handleProxyResponse(c, resp, errMsg)
133 | }
134 |
135 | func (h *FirewallHandler) forwardWithBody(c *gin.Context, method, path, errMsg string, requiredFields ...string) {
136 | backend, ok := h.lookupBackend(c)
137 | if !ok {
138 | return
139 | }
140 |
141 | var payload map[string]any
142 | if err := c.ShouldBindJSON(&payload); err != nil {
143 | writeError(c, http.StatusBadRequest, "Invalid JSON body.", nil)
144 | return
145 | }
146 |
147 | for _, field := range requiredFields {
148 | if strings.TrimSpace(fmt.Sprintf("%v", payload[field])) == "" {
149 | writeError(c, http.StatusBadRequest, fmt.Sprintf("Missing required field: %s.", field), nil)
150 | return
151 | }
152 | }
153 |
154 | resp, err := h.relay.Forward(c.Request.Context(), backend, method, path, payload)
155 | if err != nil {
156 | writeError(c, http.StatusInternalServerError, errMsg, err.Error())
157 | return
158 | }
159 | defer resp.Body.Close()
160 |
161 | handleProxyResponse(c, resp, errMsg)
162 | }
163 |
164 | func (h *FirewallHandler) lookupBackend(c *gin.Context) (*models.Backend, bool) {
165 | backendID := c.Query("backendId")
166 | if backendID == "" {
167 | writeError(c, http.StatusBadRequest, "Missing backendId query parameter.", nil)
168 | return nil, false
169 | }
170 |
171 | backend, err := h.repo.Get(c.Request.Context(), backendID)
172 | if err != nil {
173 | writeError(c, http.StatusInternalServerError, "Failed to retrieve backend configuration.", err.Error())
174 | return nil, false
175 | }
176 |
177 | if backend == nil || backend.URL == "" || backend.APIKey == "" {
178 | writeError(c, http.StatusUnauthorized, "Backend not configured or API key/URL is missing.", nil)
179 | return nil, false
180 | }
181 |
182 | return backend, true
183 | }
184 |
185 | func handleProxyResponse(c *gin.Context, resp *http.Response, errMsg string) {
186 | body, empty, err := decodeJSON(resp.Body)
187 | if err != nil {
188 | writeError(c, http.StatusInternalServerError, errMsg, err.Error())
189 | return
190 | }
191 |
192 | if resp.StatusCode < 200 || resp.StatusCode >= 300 {
193 | writeError(c, resp.StatusCode, errMsg, body)
194 | return
195 | }
196 |
197 | if empty || body == nil {
198 | c.Status(resp.StatusCode)
199 | return
200 | }
201 |
202 | c.JSON(resp.StatusCode, body)
203 | }
204 |
205 | func decodeJSON(r io.Reader) (any, bool, error) {
206 | data, err := io.ReadAll(r)
207 | if err != nil {
208 | return nil, true, err
209 | }
210 | if len(data) == 0 {
211 | return nil, true, nil
212 | }
213 | var payload any
214 | if err := json.Unmarshal(data, &payload); err != nil {
215 | return nil, true, err
216 | }
217 | return payload, false, nil
218 | }
219 |
--------------------------------------------------------------------------------
/frontend/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Select({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | size = "default",
30 | children,
31 | ...props
32 | }: React.ComponentProps & {
33 | size?: "sm" | "default"
34 | }) {
35 | return (
36 |
45 | {children}
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | function SelectContent({
54 | className,
55 | children,
56 | position = "popper",
57 | ...props
58 | }: React.ComponentProps) {
59 | return (
60 |
61 |
72 |
73 |
80 | {children}
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | function SelectLabel({
89 | className,
90 | ...props
91 | }: React.ComponentProps) {
92 | return (
93 |
98 | )
99 | }
100 |
101 | function SelectItem({
102 | className,
103 | children,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
115 |
116 |
117 |
118 |
119 |
120 | {children}
121 |
122 | )
123 | }
124 |
125 | function SelectSeparator({
126 | className,
127 | ...props
128 | }: React.ComponentProps) {
129 | return (
130 |
135 | )
136 | }
137 |
138 | function SelectScrollUpButton({
139 | className,
140 | ...props
141 | }: React.ComponentProps) {
142 | return (
143 |
151 |
152 |
153 | )
154 | }
155 |
156 | function SelectScrollDownButton({
157 | className,
158 | ...props
159 | }: React.ComponentProps) {
160 | return (
161 |
169 |
170 |
171 | )
172 | }
173 |
174 | export {
175 | Select,
176 | SelectContent,
177 | SelectGroup,
178 | SelectItem,
179 | SelectLabel,
180 | SelectScrollDownButton,
181 | SelectScrollUpButton,
182 | SelectSeparator,
183 | SelectTrigger,
184 | SelectValue,
185 | }
186 |
--------------------------------------------------------------------------------
/frontend/components/RulesTableCard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Table,
8 | TableBody,
9 | TableCell,
10 | TableHead,
11 | TableHeader,
12 | TableRow,
13 | } from "@/components/ui/table";
14 | import { Trash2, ChevronLeft, ChevronRight } from "lucide-react";
15 |
16 | export interface ParsedRule {
17 | number: string;
18 | to: string;
19 | action: string;
20 | from: string;
21 | details?: string;
22 | raw: string;
23 | }
24 |
25 | interface RulesTableCardProps {
26 | parsedRules: ParsedRule[];
27 | isSubmitting: boolean;
28 | onAddRuleClick: () => void;
29 | onDeleteRuleClick: (rule: ParsedRule) => void;
30 | }
31 |
32 | export default function RulesTableCard({
33 | parsedRules,
34 | isSubmitting,
35 | onAddRuleClick,
36 | onDeleteRuleClick,
37 | }: RulesTableCardProps) {
38 | const rulesPerPage = 8;
39 | const [currentPage, setCurrentPage] = useState(1);
40 |
41 | const totalPages = Math.max(1, Math.ceil(parsedRules.length / rulesPerPage));
42 |
43 | useEffect(() => {
44 | if (currentPage > totalPages) setCurrentPage(totalPages);
45 | }, [parsedRules, totalPages, currentPage]);
46 |
47 | const paginatedRules = parsedRules.slice(
48 | (currentPage - 1) * rulesPerPage,
49 | currentPage * rulesPerPage
50 | );
51 |
52 | return (
53 |
54 |
55 |
56 | Rules overview
57 |
58 | Current firewall rules parsed from the selected backend. Parsing may be imperfect for complex directives.
59 |
60 |
61 |
67 | Add rule
68 |
69 |
70 |
71 | {paginatedRules.length > 0 ? (
72 | <>
73 |
74 |
75 |
76 | #
77 | To
78 | Action
79 | From
80 | Details
81 | Manage
82 |
83 |
84 |
85 | {paginatedRules.map((rule) => {
86 | const isAllow = rule.action.includes("ALLOW");
87 | const actionClasses = isAllow
88 | ? "border-emerald-400/40 bg-emerald-500/20 text-emerald-100"
89 | : rule.action.includes("DENY") || rule.action.includes("REJECT")
90 | ? "border-rose-400/40 bg-rose-500/20 text-rose-100"
91 | : "border-white/20 bg-white/10 text-slate-100";
92 | return (
93 |
94 | {rule.number}
95 |
96 | {rule.to}
97 |
98 |
99 |
102 | {rule.action}
103 |
104 |
105 |
106 | {rule.from}
107 |
108 |
109 | {rule.details || "-"}
110 |
111 |
112 | onDeleteRuleClick(rule)}
116 | disabled={isSubmitting}
117 | className="h-9 w-9 rounded-2xl border border-rose-400/30 bg-rose-500/20 text-rose-100 hover:bg-rose-500/30 disabled:opacity-40"
118 | >
119 |
120 |
121 |
122 |
123 | );
124 | })}
125 |
126 |
127 | {totalPages > 1 && (
128 |
129 |
130 | setCurrentPage((p) => p - 1)}
134 | disabled={currentPage === 1 || isSubmitting}
135 | className="h-9 rounded-xl border-white/20 bg-white/10 px-3 text-slate-100 hover:bg-white/15 disabled:opacity-40"
136 | >
137 |
138 |
139 |
140 | Page {currentPage} of {totalPages}
141 |
142 | setCurrentPage((p) => p + 1)}
146 | disabled={currentPage === totalPages || isSubmitting}
147 | className="h-9 rounded-xl border-white/20 bg-white/10 px-3 text-slate-100 hover:bg-white/15 disabled:opacity-40"
148 | >
149 |
150 |
151 |
152 |
Rules sync automatically after create or delete operations.
153 |
154 | )}
155 | >
156 | ) : (
157 | No rules defined or UFW is inactive.
158 | )}
159 |
160 |
161 | );
162 | }
163 |
--------------------------------------------------------------------------------
/backend/go.sum:
--------------------------------------------------------------------------------
1 | github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
2 | github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
3 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
4 | github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
5 | github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
6 | github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
7 | github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
8 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13 | github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
14 | github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
15 | github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
16 | github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
17 | github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
18 | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
19 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
20 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
21 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
22 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
23 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
24 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
25 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
26 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
27 | github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
28 | github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
29 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
30 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
31 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
32 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
33 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
34 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
35 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
36 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
37 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
38 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
39 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
40 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
41 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
42 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
43 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
44 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
45 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
46 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
47 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
48 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
49 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
50 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
51 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
52 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
53 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
54 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
55 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
56 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
57 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
58 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
59 | github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
60 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
61 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
62 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
63 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
64 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
65 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
66 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
67 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
68 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
69 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
70 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
71 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
72 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
73 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
74 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
75 | golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
76 | golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
77 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
78 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
79 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
80 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
81 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
82 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
83 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
84 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
85 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
86 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
87 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
88 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
89 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
90 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
91 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
92 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
93 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
94 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
95 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
96 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
97 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | # UFW Control Backend API
2 |
3 | This project provides a simple Go backend server using the Gin framework to control the UFW (Uncomplicated Firewall) via a REST API. Access to the API is protected by an API key.
4 |
5 | ## Prerequisites
6 |
7 | 1. **Go:** Ensure you have Go installed (version 1.18 or later recommended).
8 | 2. **UFW:** The server running this backend must have UFW installed (`sudo apt update && sudo apt install ufw`).
9 | 3. **Sudo Permissions:** The user running this Go application needs passwordless `sudo` privileges specifically for the `ufw` command.
10 |
11 | ## Setup
12 |
13 | 1. **Clone the Repository (if applicable):**
14 | ```bash
15 | git clone
16 | cd
17 | ```
18 |
19 | 2. **Create `.env` File:**
20 | Create a file named `.env` in the project root directory with the following content:
21 | ```dotenv
22 | # UFW Backend Configuration
23 | UFW_API_KEY="your-strong-secret-key-here"
24 | PORT=8080
25 | ```
26 | - **IMPORTANT:** Replace `"your-strong-secret-key-here"` with a strong, unique secret key. This key will be required for all API requests.
27 | - You can change the `PORT` if needed.
28 |
29 | 3. **Configure Sudoers:**
30 | You **must** grant the user running this application passwordless sudo access for the `ufw` command.
31 | - Edit the sudoers file using `sudo visudo`.
32 | - Add the following line at the end, replacing `your_username` with the actual username that will run the Go application:
33 | ```
34 | your_username ALL=(ALL) NOPASSWD: /usr/sbin/ufw
35 | ```
36 | - **Warning:** Be extremely careful when editing the sudoers file. Incorrect syntax can lock you out of sudo access.
37 |
38 | 4. **Install Dependencies:**
39 | ```bash
40 | go mod tidy
41 | ```
42 |
43 | ## Running the Server
44 |
45 | ```bash
46 | go run .
47 | ```
48 | The server will start and listen on the port specified in the `.env` file (default: 8080). You should see output indicating the server is running.
49 |
50 | ## API Usage
51 |
52 | All API endpoints require the `X-API-KEY` header containing the secret key defined in your `.env` file.
53 |
54 | **Base URL:** `http://localhost:PORT` (replace `PORT` with the value from `.env`)
55 |
56 | ---
57 |
58 | ### 1. Get UFW Status
59 |
60 | - **Method:** `GET`
61 | - **Path:** `/status`
62 | - **Headers:**
63 | - `X-API-KEY: `
64 | - **Description:** Retrieves the current UFW status (active/inactive) and the list of numbered rules.
65 | - **Example (`curl`):**
66 | ```bash
67 | curl -H "X-API-KEY: your-strong-secret-key-here" http://localhost:8080/status
68 | ```
69 | - **Success Response (Example):**
70 | ```json
71 | {
72 | "status": "active",
73 | "rules": [
74 | "[ 1] 22/tcp ALLOW IN Anywhere",
75 | "[ 2] 80/tcp ALLOW IN Anywhere",
76 | "[ 3] 443/tcp ALLOW IN Anywhere",
77 | "[ 4] 8080/tcp ALLOW IN Anywhere"
78 | ]
79 | }
80 | ```
81 | - **Error Responses:** `401 Unauthorized`, `403 Forbidden`, `500 Internal Server Error`
82 |
83 | ---
84 |
85 | ### 2. Add Allow Rule
86 |
87 | - **Method:** `POST`
88 | - **Path:** `/rules/allow`
89 | - **Headers:**
90 | - `X-API-KEY: `
91 | - `Content-Type: application/json`
92 | - **Request Body (JSON):**
93 | ```json
94 | {
95 | "rule": ""
96 | }
97 | ```
98 | (e.g., `"80/tcp"`, `"allow 22"`, `"allow from 192.168.1.100"`)
99 | - **Description:** Adds a new 'allow' rule to UFW.
100 | - **Example (`curl`):**
101 | ```bash
102 | curl -X POST -H "X-API-KEY: your-strong-secret-key-here" -H "Content-Type: application/json" \
103 | -d '{"rule": "8080/tcp"}' http://localhost:8080/rules/allow
104 | ```
105 | - **Success Response:**
106 | ```json
107 | {
108 | "message": "Rule added successfully",
109 | "rule": "8080/tcp"
110 | }
111 | ```
112 | - **Error Responses:** `400 Bad Request`, `401 Unauthorized`, `403 Forbidden`, `500 Internal Server Error`
113 |
114 | ---
115 |
116 | ### 3. Add Deny Rule
117 |
118 | - **Method:** `POST`
119 | - **Path:** `/rules/deny`
120 | - **Headers:**
121 | - `X-API-KEY: `
122 | - `Content-Type: application/json`
123 | - **Request Body (JSON):**
124 | ```json
125 | {
126 | "rule": ""
127 | }
128 | ```
129 | (e.g., `"22"`, `"deny from 10.0.0.5"`)
130 | - **Description:** Adds a new 'deny' rule to UFW.
131 | - **Example (`curl`):**
132 | ```bash
133 | curl -X POST -H "X-API-KEY: your-strong-secret-key-here" -H "Content-Type: application/json" \
134 | -d '{"rule": "22"}' http://localhost:8080/rules/deny
135 | ```
136 | - **Success Response:**
137 | ```json
138 | {
139 | "message": "Deny rule added successfully",
140 | "rule": "22"
141 | }
142 | ```
143 | - **Error Responses:** `400 Bad Request`, `401 Unauthorized`, `403 Forbidden`, `500 Internal Server Error`
144 |
145 | ---
146 |
147 | ### 4. Delete Rule by Number
148 |
149 | - **Method:** `DELETE`
150 | - **Path:** `/rules/delete/:number` (replace `:number` with the rule number from `/status`)
151 | - **Headers:**
152 | - `X-API-KEY: `
153 | - **Description:** Deletes a UFW rule using its number (obtained from `ufw status numbered` or the `/status` endpoint). Requires confirmation internally (handled by the backend).
154 | - **Example (`curl`):**
155 | ```bash
156 | # Assuming rule [ 3] is the one to delete
157 | curl -X DELETE -H "X-API-KEY: your-strong-secret-key-here" http://localhost:8080/rules/delete/3
158 | ```
159 | - **Success Response:**
160 | ```json
161 | {
162 | "message": "Rule deleted successfully",
163 | "rule_number": "3"
164 | }
165 | ```
166 | - **Error Responses:** `400 Bad Request`, `401 Unauthorized`, `403 Forbidden`, `404 Not Found` (if rule number doesn't exist), `500 Internal Server Error`
167 |
168 | ---
169 |
170 | ### 5. Enable UFW
171 |
172 | - **Method:** `POST`
173 | - **Path:** `/enable`
174 | - **Headers:**
175 | - `X-API-KEY: `
176 | - **Description:** Enables the UFW firewall. Requires confirmation internally if rules exist (handled by the backend).
177 | - **Example (`curl`):**
178 | ```bash
179 | curl -X POST -H "X-API-KEY: your-strong-secret-key-here" http://localhost:8080/enable
180 | ```
181 | - **Success Response:**
182 | ```json
183 | {
184 | "message": "UFW enabled successfully (or was already active)"
185 | }
186 | ```
187 | - **Error Responses:** `401 Unauthorized`, `403 Forbidden`, `500 Internal Server Error`
188 |
189 | ---
190 |
191 | ### 6. Disable UFW
192 |
193 | - **Method:** `POST`
194 | - **Path:** `/disable`
195 | - **Headers:**
196 | - `X-API-KEY: `
197 | - **Description:** Disables the UFW firewall.
198 | - **Example (`curl`):**
199 | ```bash
200 | curl -X POST -H "X-API-KEY: your-strong-secret-key-here" http://localhost:8080/disable
201 | ```
202 | - **Success Response:**
203 | ```json
204 | {
205 | "message": "UFW disabled successfully (or was already inactive)"
206 | }
207 | ```
208 | - **Error Responses:** `401 Unauthorized`, `403 Forbidden`, `500 Internal Server Error`
209 |
210 | ---
211 |
212 | ### 7. Ping (Authenticated)
213 |
214 | - **Method:** `GET`
215 | - **Path:** `/ping`
216 | - **Headers:**
217 | - `X-API-KEY: `
218 | - **Description:** A simple authenticated endpoint to check if the API is reachable and the API key is valid.
219 | - **Example (`curl`):**
220 | ```bash
221 | curl -H "X-API-KEY: your-strong-secret-key-here" http://localhost:8080/ping
222 | ```
223 | - **Success Response:**
224 | ```json
225 | {
226 | "message": "pong"
227 | }
228 | ```
229 | - **Error Responses:** `401 Unauthorized`, `403 Forbidden`
230 |
231 | ---
232 |
233 | ### 8. Add Allow Rule From IP
234 |
235 | - **Method:** `POST`
236 | - **Path:** `/rules/allow/ip`
237 | - **Headers:**
238 | - `X-API-KEY: `
239 | - `Content-Type: application/json`
240 | - **Request Body (JSON):**
241 | ```json
242 | {
243 | "ip_address": "192.168.1.100",
244 | "port_protocol": "80/tcp" // Optional. If omitted, allows all traffic from the IP.
245 | }
246 | ```
247 | - **Description:** Adds a new 'allow' rule for traffic originating from a specific IP address. Optionally restricts the rule to a specific destination port/protocol.
248 | - **Example (`curl` - Allow all from IP):**
249 | ```bash
250 | curl -X POST -H "X-API-KEY: your-strong-secret-key-here" -H "Content-Type: application/json" \
251 | -d '{"ip_address": "192.168.1.100"}' http://localhost:8080/rules/allow/ip
252 | ```
253 | - **Example (`curl` - Allow from IP to port 80/tcp):**
254 | ```bash
255 | curl -X POST -H "X-API-KEY: your-strong-secret-key-here" -H "Content-Type: application/json" \
256 | -d '{"ip_address": "192.168.1.100", "port_protocol": "80/tcp"}' http://localhost:8080/rules/allow/ip
257 | ```
258 | - **Success Response:**
259 | ```json
260 | {
261 | "message": "Allow rule from IP added successfully",
262 | "ip_address": "192.168.1.100",
263 | "port_protocol": "80/tcp" // or "" if not provided
264 | }
265 | ```
266 | - **Error Responses:** `400 Bad Request`, `401 Unauthorized`, `403 Forbidden`, `500 Internal Server Error`
267 |
268 | ---
269 |
270 | ### 9. Add Deny Rule From IP
271 |
272 | - **Method:** `POST`
273 | - **Path:** `/rules/deny/ip`
274 | - **Headers:**
275 | - `X-API-KEY: `
276 | - `Content-Type: application/json`
277 | - **Request Body (JSON):**
278 | ```json
279 | {
280 | "ip_address": "10.0.0.5",
281 | "port_protocol": "" // Optional. If omitted, denies all traffic from the IP.
282 | }
283 | ```
284 | - **Description:** Adds a new 'deny' rule for traffic originating from a specific IP address. Optionally restricts the rule to a specific destination port/protocol.
285 | - **Example (`curl` - Deny all from IP):**
286 | ```bash
287 | curl -X POST -H "X-API-KEY: your-strong-secret-key-here" -H "Content-Type: application/json" \
288 | -d '{"ip_address": "10.0.0.5"}' http://localhost:8080/rules/deny/ip
289 | ```
290 | - **Example (`curl` - Deny from IP to port 22):**
291 | ```bash
292 | curl -X POST -H "X-API-KEY: your-strong-secret-key-here" -H "Content-Type: application/json" \
293 | -d '{"ip_address": "10.0.0.5", "port_protocol": "22"}' http://localhost:8080/rules/deny/ip
294 | ```
295 | - **Success Response:**
296 | ```json
297 | {
298 | "message": "Deny rule from IP added successfully",
299 | "ip_address": "10.0.0.5",
300 | "port_protocol": "" // or the specified port/proto
301 | }
302 | ```
303 | - **Error Responses:** `400 Bad Request`, `401 Unauthorized`, `403 Forbidden`, `500 Internal Server Error`
304 |
--------------------------------------------------------------------------------
/frontend/components/PasswordAuth.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useMemo, useState } from "react";
4 | import { motion } from "framer-motion";
5 | import { Eye, EyeOff, AlertCircle, Loader2, Lock, ShieldCheck, Server, Sparkles } from "lucide-react";
6 | import Image from "next/image";
7 |
8 | import { Input } from "@/components/ui/input";
9 | import { Button } from "@/components/ui/button";
10 | import { Label } from "@/components/ui/label";
11 | import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card";
12 | import { Alert, AlertDescription } from "@/components/ui/alert";
13 | import { resolveApiUrl } from "@/lib/api";
14 |
15 | interface PasswordAuthProps {
16 | backendUrl: string;
17 | onSuccess: () => void;
18 | onError: (message: string) => void;
19 | clearError: () => void;
20 | }
21 |
22 | export default function PasswordAuth({ backendUrl, onSuccess, onError, clearError }: PasswordAuthProps) {
23 | const [password, setPassword] = useState("");
24 | const [isLoading, setIsLoading] = useState(false);
25 | const [localError, setLocalError] = useState(null);
26 | const [show, setShow] = useState(false);
27 | const [capsOn, setCapsOn] = useState(false);
28 |
29 | const tip = useMemo(
30 | () => (password.length < 1 ? "Enter your backend password" : "Make sure you are logging in from a trusted network"),
31 | [password]
32 | );
33 |
34 | const handleSubmit = async (e: React.FormEvent) => {
35 | e.preventDefault();
36 | if (!password || isLoading) return;
37 |
38 | setIsLoading(true);
39 | setLocalError(null);
40 | clearError();
41 |
42 | const apiUrl = new URL(resolveApiUrl("/api/auth"));
43 | apiUrl.searchParams.append("backendUrl", backendUrl);
44 |
45 | try {
46 | const response = await fetch(apiUrl.toString(), {
47 | method: "POST",
48 | headers: { "Content-Type": "application/json" },
49 | credentials: "include",
50 | body: JSON.stringify({ password }),
51 | });
52 |
53 | const data = await response.json();
54 |
55 | if (response.ok && data.authenticated) {
56 | onSuccess();
57 | } else {
58 | const errorMessage = data.error || "Authentication failed.";
59 | setLocalError(errorMessage);
60 | onError(errorMessage);
61 | }
62 | } catch (err) {
63 | console.error("API call failed:", err);
64 | const errorMessage = "An error occurred during authentication.";
65 | setLocalError(errorMessage);
66 | onError(errorMessage);
67 | } finally {
68 | setIsLoading(false);
69 | }
70 | };
71 |
72 | useEffect(() => {
73 | const onKey = (e: KeyboardEvent) => {
74 | const caps = typeof e.getModifierState === "function" ? e.getModifierState("CapsLock") : false;
75 | setCapsOn(!!caps);
76 | };
77 | window.addEventListener("keydown", onKey);
78 | window.addEventListener("keyup", onKey);
79 | return () => {
80 | window.removeEventListener("keydown", onKey);
81 | window.removeEventListener("keyup", onKey);
82 | };
83 | }, []);
84 |
85 | return (
86 |
87 |
92 |
93 |
94 |
100 |
101 |
102 |
103 | Unlock your firewall control
104 | Quickly登录,随时管理所有 UFW 节点。
105 |
106 |
107 |
113 |
114 | Secure Access
115 |
116 |
117 |
118 | Sign in to manage your UFW nodes with confidence
119 |
120 |
121 | A refined authentication flow keeps your firewall operations protected while giving you quick access to
122 | the tools you rely on every day.
123 |
124 |
125 |
126 |
127 |
128 |
129 |
Zero-trust entry
130 |
Authenticate before modifying sensitive firewall policies.
131 |
132 |
133 |
134 |
135 |
136 |
Unified control
137 |
Connect to every registered backend from a single panel.
138 |
139 |
140 |
141 |
142 |
143 |
149 |
150 |
153 |
154 |
155 |
156 |
157 |
158 | Welcome back
159 |
160 | Use your backend password to unlock the control panel.
161 |
162 |
163 |
164 |
230 |
231 |
232 |
233 |
234 | );
235 | }
236 |
--------------------------------------------------------------------------------
/frontend/go.sum:
--------------------------------------------------------------------------------
1 | github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
2 | github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
3 | github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
4 | github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
5 | github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
6 | github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
7 | github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
8 | github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
13 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
14 | github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
15 | github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
16 | github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
17 | github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
18 | github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
19 | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
20 | github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
21 | github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
22 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
23 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
24 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
25 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
26 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
27 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
28 | github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
29 | github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
30 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
31 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
32 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
33 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
34 | github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
35 | github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
36 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
37 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
38 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
39 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
40 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
41 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
42 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
43 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
44 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
45 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
46 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
47 | github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
48 | github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
49 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
50 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
51 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
52 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
53 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
54 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
55 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
56 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
57 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
58 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
59 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
60 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
61 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
62 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
63 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
64 | github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
65 | github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
66 | github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
67 | github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
68 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
69 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
70 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
71 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
72 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
73 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
74 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
75 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
76 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
77 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
78 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
79 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
80 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
81 | github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
82 | github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
83 | go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
84 | go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
85 | golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
86 | golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
87 | golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
88 | golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
89 | golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
90 | golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
91 | golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
92 | golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
93 | golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
94 | golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
95 | golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
96 | golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
97 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
98 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
99 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
100 | golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
101 | golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
102 | golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
103 | golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
104 | google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
105 | google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
106 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
107 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
108 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
109 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
110 | modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
111 | modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
112 | modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
113 | modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
114 | modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk=
115 | modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
116 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
117 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
118 | modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
119 | modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
120 | modernc.org/libc v1.66.9 h1:YkHp7E1EWrN2iyNav7JE/nHasmshPvlGkon1VxGqOw0=
121 | modernc.org/libc v1.66.9/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k=
122 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
123 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
124 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
125 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
126 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
127 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
128 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
129 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
130 | modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
131 | modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
132 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
133 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
134 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
135 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
136 |
--------------------------------------------------------------------------------
/frontend/components/AddBackendDialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect, useCallback } from "react";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Dialog,
7 | DialogContent,
8 | DialogDescription,
9 | DialogFooter,
10 | DialogHeader,
11 | DialogTitle,
12 | DialogClose,
13 | } from "@/components/ui/dialog";
14 | import { Input } from "@/components/ui/input";
15 | import { Label } from "@/components/ui/label";
16 | import {
17 | Select,
18 | SelectTrigger,
19 | SelectValue,
20 | SelectContent,
21 | SelectItem,
22 | } from "@/components/ui/select";
23 | import { AlertCircle } from "lucide-react";
24 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
25 | import { cn } from "@/lib/utils";
26 | import { BackendConfig } from "@/lib/types";
27 |
28 | export interface AddBackendFormData {
29 | name: string;
30 | url: string;
31 | apiKey: string;
32 | }
33 |
34 | type BackendDialogMode = "create" | "edit";
35 |
36 | interface AddBackendDialogProps {
37 | isOpen: boolean;
38 | onOpenChange: (open: boolean) => void;
39 | mode: BackendDialogMode;
40 | backend?: BackendConfig | null;
41 | onCreate: (formData: AddBackendFormData) => void;
42 | onUpdate: (backendId: string, formData: AddBackendFormData) => void;
43 | }
44 |
45 | export default function AddBackendDialog({
46 | isOpen,
47 | onOpenChange,
48 | mode,
49 | backend,
50 | onCreate,
51 | onUpdate,
52 | }: AddBackendDialogProps) {
53 | const [name, setName] = useState("");
54 | const [scheme, setScheme] = useState<"http" | "https">("https");
55 | const [host, setHost] = useState("");
56 | const [port, setPort] = useState("");
57 | const [apiKey, setApiKey] = useState("");
58 | const [error, setError] = useState(null);
59 |
60 | const resetForm = useCallback(() => {
61 | setName("");
62 | setScheme("https");
63 | setHost("");
64 | setPort("");
65 | setApiKey("");
66 | setError(null);
67 | }, []);
68 |
69 | const prefillFromBackend = useCallback(
70 | (backendConfig: BackendConfig) => {
71 | setName(backendConfig.name ?? "");
72 | let detectedScheme: "http" | "https" = "https";
73 | let detectedHost = "";
74 | let detectedPort = "";
75 |
76 | try {
77 | const parsed = new URL(backendConfig.url);
78 | const protocol = parsed.protocol.replace(":", "");
79 | if (protocol === "http" || protocol === "https") {
80 | detectedScheme = protocol;
81 | }
82 | detectedHost = parsed.hostname || backendConfig.url;
83 | detectedPort = parsed.port || "";
84 | } catch {
85 | detectedHost = backendConfig.url;
86 | }
87 |
88 | setScheme(detectedScheme);
89 | setHost(detectedHost);
90 | setPort(detectedPort);
91 | setApiKey("");
92 | setError(null);
93 | },
94 | []
95 | );
96 |
97 | useEffect(() => {
98 | if (!isOpen) {
99 | return;
100 | }
101 |
102 | if (mode === "edit" && backend) {
103 | prefillFromBackend(backend);
104 | } else {
105 | resetForm();
106 | }
107 | }, [isOpen, mode, backend, prefillFromBackend, resetForm]);
108 |
109 | const validateHost = (value: string) => {
110 | const hostRegex = /^(localhost|\d{1,3}(?:\.\d{1,3}){3}|[a-zA-Z0-9.-]+)$/;
111 | return hostRegex.test(value);
112 | };
113 |
114 | const handleSave = () => {
115 | setError(null);
116 |
117 | if (!name.trim()) {
118 | setError("Backend name cannot be empty.");
119 | return;
120 | }
121 |
122 | if (!host.trim()) {
123 | setError("Backend host cannot be empty.");
124 | return;
125 | }
126 |
127 | if (!validateHost(host.trim())) {
128 | setError("Invalid host format. Use IP address, domain, or 'localhost'.");
129 | return;
130 | }
131 |
132 | if (!port.trim()) {
133 | setError("Port cannot be empty.");
134 | return;
135 | }
136 |
137 | const portNum = Number(port.trim());
138 | if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
139 | setError("Port must be an integer between 1 and 65535.");
140 | return;
141 | }
142 |
143 | if (mode === "create" && !apiKey.trim()) {
144 | setError("API Key cannot be empty.");
145 | return;
146 | }
147 |
148 | const url = `${scheme}://${host.trim()}:${portNum}`;
149 | const trimmedApiKey = apiKey.trim();
150 |
151 | const payload: AddBackendFormData = {
152 | name: name.trim(),
153 | url,
154 | apiKey: trimmedApiKey,
155 | };
156 |
157 | if (mode === "edit" && backend) {
158 | onUpdate(backend.id, payload);
159 | } else {
160 | onCreate(payload);
161 | }
162 | };
163 |
164 | const isEditMode = mode === "edit";
165 |
166 | return (
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 | {isEditMode ? "Edit Backend" : "Add New Backend"}
176 |
177 |
178 | {isEditMode
179 | ? "Update the target endpoint details for this backend. Leave API Key blank to keep the existing value."
180 | : "Select protocol, enter host and port, and provide the API Key for the backend server."}
181 |
182 |
183 |
184 |
185 | {error && (
186 |
190 |
191 | Error
192 |
193 | {error}
194 |
195 |
196 | )}
197 |
198 |
199 |
203 | Name
204 |
205 | setName(e.target.value)}
209 | className={cn(
210 | "h-11 rounded-2xl border-white/15 bg-white/10 text-sm font-medium text-slate-100 placeholder:text-slate-400/80 shadow-inner",
211 | "focus-visible:border-indigo-400/70 focus-visible:ring-indigo-400/30"
212 | )}
213 | placeholder="e.g., Production Server"
214 | required
215 | />
216 |
217 |
218 |
219 |
220 | Protocol
221 |
222 | setScheme(v as "http" | "https")}>
223 |
224 |
225 |
226 |
227 | http
228 | https
229 |
230 |
231 |
232 |
233 |
265 |
266 |
267 |
268 |
269 | API Key {isEditMode && (leave blank to keep) }
270 |
271 | setApiKey(e.target.value)}
275 | className={cn(
276 | "h-11 rounded-2xl border-white/15 bg-white/10 text-sm font-medium text-slate-100 placeholder:text-slate-400/80 shadow-inner",
277 | "focus-visible:border-indigo-400/70 focus-visible:ring-indigo-400/30"
278 | )}
279 | placeholder={isEditMode ? "Leave blank to reuse existing key" : "Enter backend specific API Key"}
280 | type="password"
281 | required={mode === "create"}
282 | />
283 |
284 |
285 |
286 |
287 |
292 | Cancel
293 |
294 |
295 |
300 | {isEditMode ? "Save changes" : "Save Backend"}
301 |
302 |
303 |
304 |
305 |
306 | );
307 | }
308 |
--------------------------------------------------------------------------------
/backend/ufw.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "net"
9 | "os"
10 | "os/exec"
11 | "regexp"
12 | "strconv"
13 | "strings"
14 | "time"
15 | )
16 |
17 | type UFWStatus struct {
18 | Status string `json:"status"`
19 | Rules []string `json:"rules"`
20 | }
21 |
22 | var (
23 | reRuleNumberLine = regexp.MustCompile(`^\s*\[\s*\d+\s*\]\s+.+$`)
24 | reDigits = regexp.MustCompile(`^\d+$`)
25 | reHeaderDashes = regexp.MustCompile(`^-{3,}$`)
26 | )
27 |
28 | func ufwPath() (string, error) {
29 | return exec.LookPath("ufw")
30 | }
31 |
32 | func shouldUseSudo() bool {
33 | return os.Getenv("UFW_SUDO") == "1"
34 | }
35 |
36 | func ufwTimeout() time.Duration {
37 | if v := os.Getenv("UFW_TIMEOUT_SEC"); v != "" {
38 | if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 60 {
39 | return time.Duration(n) * time.Second
40 | }
41 | }
42 | return 5 * time.Second
43 | }
44 |
45 | type cmdResult struct {
46 | Stdout string
47 | Stderr string
48 | ExitCode int
49 | }
50 |
51 | func runUFW(args ...string) (*cmdResult, error) {
52 | path, err := ufwPath()
53 | if err != nil {
54 | return nil, fmt.Errorf("ufw not found: %w", err)
55 | }
56 | finalArgs := args
57 | if shouldUseSudo() {
58 | finalArgs = append([]string{path}, args...)
59 | path = "sudo"
60 | }
61 | ctx, cancel := context.WithTimeout(context.Background(), ufwTimeout())
62 | defer cancel()
63 | cmd := exec.CommandContext(ctx, path, finalArgs...)
64 | cmd.Env = append(os.Environ(), "LANG=C")
65 | var out, er bytes.Buffer
66 | cmd.Stdout = &out
67 | cmd.Stderr = &er
68 | err = cmd.Run()
69 | res := &cmdResult{
70 | Stdout: out.String(),
71 | Stderr: er.String(),
72 | ExitCode: func() int {
73 | if err == nil {
74 | return 0
75 | }
76 | var ee *exec.ExitError
77 | if errors.As(err, &ee) {
78 | return ee.ExitCode()
79 | }
80 | if errors.Is(err, context.DeadlineExceeded) {
81 | return -2
82 | }
83 | return -1
84 | }(),
85 | }
86 | if errors.Is(err, context.DeadlineExceeded) {
87 | return res, fmt.Errorf("ufw command timeout: %s %s", path, strings.Join(finalArgs, " "))
88 | }
89 | if err != nil {
90 | return res, fmt.Errorf("ufw command failed: %s %s\nstderr: %s", path, strings.Join(finalArgs, " "), res.Stderr)
91 | }
92 | return res, nil
93 | }
94 |
95 | func runUFWForce(args ...string) (*cmdResult, error) {
96 | args = append([]string{"--force"}, args...)
97 | return runUFW(args...)
98 | }
99 |
100 | func validatePort(port string) error {
101 | if port == "" {
102 | return errors.New("port empty")
103 | }
104 | if strings.Contains(port, ":") {
105 | parts := strings.SplitN(port, ":", 2)
106 | if len(parts) != 2 {
107 | return fmt.Errorf("invalid port range: %s", port)
108 | }
109 | a, b := parts[0], parts[1]
110 | if err := validatePort(a); err != nil {
111 | return err
112 | }
113 | if err := validatePort(b); err != nil {
114 | return err
115 | }
116 | ai, _ := strconv.Atoi(a)
117 | bi, _ := strconv.Atoi(b)
118 | if ai > bi {
119 | return fmt.Errorf("invalid port range: start > end")
120 | }
121 | return nil
122 | }
123 | n, err := strconv.Atoi(port)
124 | if err != nil || n < 1 || n > 65535 {
125 | return fmt.Errorf("invalid port: %s", port)
126 | }
127 | return nil
128 | }
129 |
130 | func validateProto(p string) error {
131 | if p == "" {
132 | return nil
133 | }
134 | switch strings.ToLower(p) {
135 | case "tcp", "udp":
136 | return nil
137 | default:
138 | return fmt.Errorf("invalid protocol: %s", p)
139 | }
140 | }
141 |
142 | func validateIPorCIDR(s string) error {
143 | if s == "" {
144 | return nil
145 | }
146 | if strings.Contains(s, "/") {
147 | if _, _, err := net.ParseCIDR(s); err != nil {
148 | return fmt.Errorf("invalid CIDR: %s", s)
149 | }
150 | return nil
151 | }
152 | if net.ParseIP(s) == nil {
153 | return fmt.Errorf("invalid IP: %s", s)
154 | }
155 | return nil
156 | }
157 |
158 | func validateComment(c string) error {
159 | if len(c) > 80 {
160 | return fmt.Errorf("comment too long (<=80)")
161 | }
162 | if strings.ContainsAny(c, "\n\r\t`$&|;<>()\\\"'") {
163 | return fmt.Errorf("comment contains illegal chars")
164 | }
165 | return nil
166 | }
167 |
168 | func GetUFWStatus() (*UFWStatus, error) {
169 | res, err := runUFW("status", "numbered")
170 | if err != nil {
171 | if res != nil && (strings.Contains(res.Stderr, "Status: inactive") || strings.Contains(res.Stdout, "Status: inactive") || strings.Contains(res.Stdout, "inactive")) {
172 | return &UFWStatus{Status: "inactive", Rules: []string{}}, nil
173 | }
174 | return nil, err
175 | }
176 |
177 | out := strings.TrimSpace(res.Stdout)
178 | if out == "" {
179 | return nil, fmt.Errorf("empty output from ufw status")
180 | }
181 |
182 | lines := strings.Split(strings.ReplaceAll(out, "\r\n", "\n"), "\n")
183 | for i := range lines {
184 | lines[i] = strings.TrimRight(lines[i], " \t")
185 | }
186 |
187 | status := &UFWStatus{Status: "unknown", Rules: []string{}}
188 | if len(lines) > 0 && strings.HasPrefix(strings.TrimSpace(lines[0]), "Status:") {
189 | status.Status = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[0]), "Status:"))
190 | }
191 |
192 | for _, ln := range lines {
193 | if reRuleNumberLine.MatchString(ln) {
194 | status.Rules = append(status.Rules, strings.TrimSpace(ln))
195 | }
196 | }
197 |
198 | if len(status.Rules) == 0 {
199 | start := -1
200 | for i, ln := range lines {
201 | s := strings.ToLower(strings.TrimSpace(ln))
202 | if strings.Contains(s, "action") && strings.Contains(s, "from") {
203 | start = i + 1
204 | break
205 | }
206 | }
207 | if start != -1 {
208 | if start < len(lines) && reHeaderDashes.MatchString(strings.TrimSpace(lines[start])) {
209 | start++
210 | }
211 | for i := start; i < len(lines); i++ {
212 | l := strings.TrimSpace(lines[i])
213 | if l == "" {
214 | continue
215 | }
216 | if strings.HasPrefix(strings.ToLower(l), "logging") {
217 | continue
218 | }
219 | status.Rules = append(status.Rules, l)
220 | }
221 | }
222 | }
223 |
224 | return status, nil
225 | }
226 |
227 | func AllowUFWPort(rule string, comment string) error {
228 | rule = strings.TrimSpace(rule)
229 | if rule == "" {
230 | return fmt.Errorf("rule cannot be empty")
231 | }
232 | parts := strings.Split(rule, "/")
233 | switch len(parts) {
234 | case 1:
235 | if _, err := strconv.Atoi(parts[0]); err == nil || strings.Contains(parts[0], ":") {
236 | if err := validatePort(parts[0]); err != nil {
237 | return err
238 | }
239 | }
240 | case 2:
241 | if parts[0] != "" {
242 | if _, err := strconv.Atoi(parts[0]); err == nil || strings.Contains(parts[0], ":") {
243 | if err := validatePort(parts[0]); err != nil {
244 | return err
245 | }
246 | }
247 | }
248 | if err := validateProto(parts[1]); err != nil {
249 | return err
250 | }
251 | default:
252 | return fmt.Errorf("invalid rule format: %s", rule)
253 | }
254 | if comment != "" {
255 | if err := validateComment(comment); err != nil {
256 | return err
257 | }
258 | }
259 | args := []string{"allow", rule}
260 | if comment != "" {
261 | args = append(args, "comment", comment)
262 | }
263 | if _, err := runUFW(args...); err != nil {
264 | if strings.Contains(err.Error(), "Skipping adding existing rule") {
265 | return nil
266 | }
267 | return err
268 | }
269 | return nil
270 | }
271 |
272 | func DenyUFWPort(rule string, comment string) error {
273 | rule = strings.TrimSpace(rule)
274 | if rule == "" {
275 | return fmt.Errorf("rule cannot be empty")
276 | }
277 | parts := strings.Split(rule, "/")
278 | switch len(parts) {
279 | case 1:
280 | if _, err := strconv.Atoi(parts[0]); err == nil || strings.Contains(parts[0], ":") {
281 | if err := validatePort(parts[0]); err != nil {
282 | return err
283 | }
284 | }
285 | case 2:
286 | if parts[0] != "" {
287 | if _, err := strconv.Atoi(parts[0]); err == nil || strings.Contains(parts[0], ":") {
288 | if err := validatePort(parts[0]); err != nil {
289 | return err
290 | }
291 | }
292 | }
293 | if err := validateProto(parts[1]); err != nil {
294 | return err
295 | }
296 | default:
297 | return fmt.Errorf("invalid rule format: %s", rule)
298 | }
299 | if comment != "" {
300 | if err := validateComment(comment); err != nil {
301 | return err
302 | }
303 | }
304 | args := []string{"deny", rule}
305 | if comment != "" {
306 | args = append(args, "comment", comment)
307 | }
308 | if _, err := runUFW(args...); err != nil {
309 | if strings.Contains(err.Error(), "Skipping adding existing rule") {
310 | return nil
311 | }
312 | return err
313 | }
314 | return nil
315 | }
316 |
317 | func DeleteUFWByNumber(ruleNumber string) error {
318 | ruleNumber = strings.TrimSpace(ruleNumber)
319 | if !reDigits.MatchString(ruleNumber) || ruleNumber == "0" {
320 | return fmt.Errorf("invalid rule number: %s", ruleNumber)
321 | }
322 | res, err := runUFWForce("delete", ruleNumber)
323 | if err != nil {
324 | if res != nil && (strings.Contains(res.Stderr, "Rule not found") || strings.Contains(err.Error(), "Rule not found")) {
325 | return fmt.Errorf("rule number %s not found", ruleNumber)
326 | }
327 | return err
328 | }
329 | _ = res
330 | return nil
331 | }
332 |
333 | func EnableUFW() error {
334 | res, err := runUFWForce("enable")
335 | if err != nil {
336 | if res != nil && (strings.Contains(res.Stderr, "already active") || strings.Contains(res.Stdout, "already active")) {
337 | return nil
338 | }
339 | return err
340 | }
341 | return nil
342 | }
343 |
344 | func DisableUFW() error {
345 | res, err := runUFW("disable")
346 | if err != nil {
347 | if res != nil && (strings.Contains(res.Stderr, "not active") || strings.Contains(res.Stdout, "not active")) {
348 | return nil
349 | }
350 | return err
351 | }
352 | return nil
353 | }
354 |
355 | func AllowUFWFromIP(ipAddress string, portProto string, comment string) error {
356 | ipAddress = strings.TrimSpace(ipAddress)
357 | if ipAddress == "" {
358 | return fmt.Errorf("ip address cannot be empty")
359 | }
360 | if err := validateIPorCIDR(ipAddress); err != nil {
361 | return err
362 | }
363 | pp := strings.TrimSpace(portProto)
364 | var port, proto string
365 | if pp != "" {
366 | if strings.Contains(pp, "/") {
367 | parts := strings.SplitN(pp, "/", 2)
368 | port = strings.TrimSpace(parts[0])
369 | proto = strings.TrimSpace(parts[1])
370 | } else {
371 | if _, err := strconv.Atoi(pp); err == nil || strings.Contains(pp, ":") {
372 | port = pp
373 | } else {
374 | proto = pp
375 | }
376 | }
377 | }
378 | if port != "" {
379 | if err := validatePort(port); err != nil {
380 | return err
381 | }
382 | }
383 | if proto != "" {
384 | if err := validateProto(proto); err != nil {
385 | return err
386 | }
387 | }
388 | if comment != "" {
389 | if err := validateComment(comment); err != nil {
390 | return err
391 | }
392 | }
393 | args := []string{"allow", "from", ipAddress, "to", "any"}
394 | if port != "" {
395 | args = append(args, "port", port)
396 | }
397 | if proto != "" {
398 | args = append(args, "proto", proto)
399 | }
400 | if comment != "" {
401 | args = append(args, "comment", comment)
402 | }
403 | if _, err := runUFW(args...); err != nil {
404 | if strings.Contains(err.Error(), "Skipping adding existing rule") {
405 | return nil
406 | }
407 | return err
408 | }
409 | return nil
410 | }
411 |
412 | func DenyUFWFromIP(ipAddress string, portProto string, comment string) error {
413 | ipAddress = strings.TrimSpace(ipAddress)
414 | if ipAddress == "" {
415 | return fmt.Errorf("ip address cannot be empty")
416 | }
417 | if err := validateIPorCIDR(ipAddress); err != nil {
418 | return err
419 | }
420 | pp := strings.TrimSpace(portProto)
421 | var port, proto string
422 | if pp != "" {
423 | if strings.Contains(pp, "/") {
424 | parts := strings.SplitN(pp, "/", 2)
425 | port = strings.TrimSpace(parts[0])
426 | proto = strings.TrimSpace(parts[1])
427 | } else {
428 | if _, err := strconv.Atoi(pp); err == nil || strings.Contains(pp, ":") {
429 | port = pp
430 | } else {
431 | proto = pp
432 | }
433 | }
434 | }
435 | if port != "" {
436 | if err := validatePort(port); err != nil {
437 | return err
438 | }
439 | }
440 | if proto != "" {
441 | if err := validateProto(proto); err != nil {
442 | return err
443 | }
444 | }
445 | if comment != "" {
446 | if err := validateComment(comment); err != nil {
447 | return err
448 | }
449 | }
450 | args := []string{"deny", "from", ipAddress, "to", "any"}
451 | if port != "" {
452 | args = append(args, "port", port)
453 | }
454 | if proto != "" {
455 | args = append(args, "proto", proto)
456 | }
457 | if comment != "" {
458 | args = append(args, "comment", comment)
459 | }
460 | if _, err := runUFW(args...); err != nil {
461 | if strings.Contains(err.Error(), "Skipping adding existing rule") {
462 | return nil
463 | }
464 | return err
465 | }
466 | return nil
467 | }
468 |
469 | func RouteAllowUFW(protocol, fromIP, toIP, port, comment string) error {
470 | protocol = strings.TrimSpace(protocol)
471 | fromIP = strings.TrimSpace(fromIP)
472 | toIP = strings.TrimSpace(toIP)
473 | port = strings.TrimSpace(port)
474 | comment = strings.TrimSpace(comment)
475 | if protocol == "" && port == "" {
476 | return fmt.Errorf("invalid request: protocol or port required")
477 | }
478 | if protocol != "" {
479 | if err := validateProto(protocol); err != nil {
480 | return err
481 | }
482 | }
483 | if fromIP != "" && fromIP != "any" {
484 | if err := validateIPorCIDR(fromIP); err != nil {
485 | return fmt.Errorf("from ip invalid: %v", err)
486 | }
487 | }
488 | if toIP != "" && toIP != "any" {
489 | if err := validateIPorCIDR(toIP); err != nil {
490 | return fmt.Errorf("to ip invalid: %v", err)
491 | }
492 | }
493 | if port != "" {
494 | if err := validatePort(port); err != nil {
495 | return err
496 | }
497 | }
498 | if comment != "" {
499 | if err := validateComment(comment); err != nil {
500 | return err
501 | }
502 | }
503 | args := []string{"route", "allow"}
504 | if protocol != "" {
505 | args = append(args, "proto", protocol)
506 | }
507 | if fromIP == "" {
508 | fromIP = "any"
509 | }
510 | if toIP == "" {
511 | toIP = "any"
512 | }
513 | args = append(args, "from", fromIP, "to", toIP)
514 | if port != "" {
515 | args = append(args, "port", port)
516 | }
517 | if comment != "" {
518 | args = append(args, "comment", comment)
519 | }
520 | if _, err := runUFW(args...); err != nil {
521 | if strings.Contains(err.Error(), "Skipping adding existing rule") {
522 | return nil
523 | }
524 | return err
525 | }
526 | return nil
527 | }
528 |
--------------------------------------------------------------------------------
/backend/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/rand"
5 | "crypto/rsa"
6 | "crypto/x509"
7 | "crypto/x509/pkix"
8 | "encoding/pem"
9 | "fmt"
10 | "log"
11 | "math/big"
12 | "net"
13 | "net/http"
14 | "net/url"
15 | "os"
16 | "strconv"
17 | "strings"
18 | "sync"
19 | "time"
20 |
21 | "github.com/gin-contrib/cors"
22 | "github.com/gin-gonic/gin"
23 | "github.com/joho/godotenv"
24 | )
25 |
26 | const (
27 | certFileName = "server.crt"
28 | keyFileName = "server.key"
29 | )
30 |
31 | var expectedAPIKey string
32 |
33 | type failInfo struct {
34 | Count int
35 | First time.Time
36 | }
37 |
38 | var (
39 | failedAttempts sync.Map
40 | blockedIPs sync.Map
41 | failWindow = time.Minute
42 | )
43 |
44 | var maxFails int
45 |
46 | func init() {
47 | maxFailsStr := os.Getenv("MAX_FAILS")
48 | if maxFailsStr == "" {
49 | log.Println("Warning: MAX_FAILS environment variable not set. Defaulting to 5.")
50 | maxFails = 5
51 | } else {
52 | var err error
53 | maxFails, err = strconv.Atoi(maxFailsStr)
54 | if err != nil {
55 | log.Fatalf("FATAL: Invalid MAX_FAILS value: %s. Must be an integer.", maxFailsStr)
56 | }
57 | }
58 | }
59 |
60 | func ensureSelfSignedCert(certPath, keyPath string) error {
61 | if _, err := os.Stat(certPath); err == nil {
62 | if _, err = os.Stat(keyPath); err == nil {
63 | log.Printf("Detected existing self-signed certificate, skipping generation (%s, %s)", certPath, keyPath)
64 | return nil
65 | }
66 | }
67 |
68 | log.Println("No self-signed certificate found, starting generation…")
69 |
70 | priv, err := rsa.GenerateKey(rand.Reader, 2048)
71 | if err != nil {
72 | return fmt.Errorf("failed to generate private key: %w", err)
73 | }
74 |
75 | max := new(big.Int)
76 | max.Lsh(big.NewInt(1), 128)
77 | serialNumber, err := rand.Int(rand.Reader, max)
78 | if err != nil {
79 | return fmt.Errorf("failed to generate serial number: %w", err)
80 | }
81 | template := x509.Certificate{
82 | SerialNumber: serialNumber,
83 | Subject: pkix.Name{
84 | Organization: []string{"UFW-Panel"},
85 | CommonName: "localhost",
86 | },
87 | NotBefore: time.Now().Add(-time.Hour),
88 | NotAfter: time.Now().AddDate(10, 0, 0),
89 |
90 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
91 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
92 | BasicConstraintsValid: true,
93 | DNSNames: []string{"localhost"},
94 | IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
95 | }
96 |
97 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
98 | if err != nil {
99 | return fmt.Errorf("failed to generate certificate: %w", err)
100 | }
101 |
102 | certOut, err := os.Create(certPath)
103 | if err != nil {
104 | return fmt.Errorf("failed to create certificate file: %w", err)
105 | }
106 | defer certOut.Close()
107 | if err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
108 | return fmt.Errorf("failed to write certificate: %w", err)
109 | }
110 |
111 | keyOut, err := os.Create(keyPath)
112 | if err != nil {
113 | return fmt.Errorf("failed to create private key file: %w", err)
114 | }
115 | defer keyOut.Close()
116 | if err = pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil {
117 | return fmt.Errorf("failed to write private key: %w", err)
118 | }
119 |
120 | log.Printf("Self-signed certificate created (%s, %s)", certPath, keyPath)
121 | return nil
122 | }
123 |
124 | func AuthMiddleware() gin.HandlerFunc {
125 | expectedAPIKey = os.Getenv("UFW_API_KEY")
126 | if expectedAPIKey == "" {
127 | log.Fatal("FATAL: UFW_API_KEY not set")
128 | }
129 |
130 | return func(c *gin.Context) {
131 | if c.Request.Method == http.MethodOptions {
132 | c.Next()
133 | return
134 | }
135 |
136 | ip := c.ClientIP()
137 |
138 | if _, blocked := blockedIPs.Load(ip); blocked {
139 | c.JSON(http.StatusForbidden, gin.H{"error": "IP blocked"})
140 | c.Abort()
141 | return
142 | }
143 |
144 | apiKey := c.GetHeader("X-API-KEY")
145 | if apiKey == "" || apiKey != expectedAPIKey {
146 | now := time.Now()
147 | val, _ := failedAttempts.LoadOrStore(ip, &failInfo{Count: 0, First: now})
148 | fi := val.(*failInfo)
149 |
150 | if now.Sub(fi.First) > failWindow {
151 | fi.Count = 1
152 | fi.First = now
153 | } else {
154 | fi.Count++
155 | }
156 |
157 | if fi.Count >= maxFails {
158 | blockedIPs.Store(ip, struct{}{})
159 | go func() {
160 | if err := DenyUFWFromIP(ip, "", fmt.Sprintf("AUTO BLOCK: %d fails/m", maxFails)); err != nil {
161 | log.Printf("WARN: failed to add UFW deny rule for %s: %v", ip, err)
162 | }
163 | }()
164 | c.JSON(http.StatusForbidden, gin.H{"error": "Too many failed attempts, IP blocked"})
165 | } else {
166 | c.JSON(http.StatusForbidden, gin.H{"error": "Invalid or missing API key"})
167 | }
168 | c.Abort()
169 | return
170 | }
171 |
172 | failedAttempts.Delete(ip)
173 | c.Next()
174 | }
175 | }
176 |
177 | func main() {
178 | if err := godotenv.Load(); err != nil {
179 | log.Println("Warning: Could not load .env file:", err)
180 | }
181 |
182 | router := gin.Default()
183 |
184 | allowedOriginsEnv := os.Getenv("CORS_ALLOWED_ORIGINS")
185 | rawItems := []string{}
186 | if allowedOriginsEnv != "" {
187 | for _, v := range strings.Split(allowedOriginsEnv, ",") {
188 | if vv := strings.TrimSpace(v); vv != "" {
189 | rawItems = append(rawItems, vv)
190 | }
191 | }
192 | }
193 | if len(rawItems) == 0 {
194 | rawItems = []string{"http://localhost:3000"}
195 | log.Println("Warning: CORS_ALLOWED_ORIGINS not set. Defaulting to http://localhost:3000")
196 | }
197 | log.Printf("CORS raw allow list: %v", rawItems)
198 |
199 | type originRule struct {
200 | exact string
201 | glob string
202 | }
203 | var rules []originRule
204 | for _, it := range rawItems {
205 | if strings.HasPrefix(it, "*.") {
206 | rules = append(rules, originRule{glob: strings.TrimPrefix(it, "*.")})
207 | } else {
208 | rules = append(rules, originRule{exact: it})
209 | }
210 | }
211 |
212 | allowOriginFunc := func(origin string) bool {
213 | for _, r := range rules {
214 | if r.exact != "" && origin == r.exact {
215 | return true
216 | }
217 | }
218 | u, err := url.Parse(origin)
219 | if err != nil {
220 | return false
221 | }
222 | host := u.Hostname()
223 | for _, r := range rules {
224 | if r.glob != "" && (host == r.glob || strings.HasSuffix(host, "."+r.glob)) {
225 | return true
226 | }
227 | }
228 | return false
229 | }
230 |
231 | router.Use(cors.New(cors.Config{
232 | AllowOriginFunc: allowOriginFunc,
233 | AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
234 | AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-API-KEY", "Authorization"},
235 | ExposeHeaders: []string{"Content-Length"},
236 | AllowCredentials: true,
237 | MaxAge: 12 * time.Hour,
238 | }))
239 |
240 | authorized := router.Group("/")
241 | authorized.Use(AuthMiddleware())
242 | {
243 | authorized.GET("/ping", func(c *gin.Context) {
244 | c.JSON(http.StatusOK, gin.H{"message": "pong"})
245 | })
246 |
247 | authorized.GET("/status", func(c *gin.Context) {
248 | status, err := GetUFWStatus()
249 | if err != nil {
250 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get UFW status", "details": err.Error()})
251 | return
252 | }
253 | c.JSON(http.StatusOK, status)
254 | })
255 |
256 | type AllowRuleRequest struct {
257 | Rule string `json:"rule" binding:"required"`
258 | Comment string `json:"comment"`
259 | }
260 | authorized.POST("/rules/allow", func(c *gin.Context) {
261 | var req AllowRuleRequest
262 | if err := c.ShouldBindJSON(&req); err != nil {
263 | c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
264 | return
265 | }
266 | if err := AllowUFWPort(req.Rule, req.Comment); err != nil {
267 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add allow rule", "details": err.Error()})
268 | return
269 | }
270 | c.JSON(http.StatusOK, gin.H{"message": "Rule added successfully", "rule": req.Rule, "comment": req.Comment})
271 | })
272 |
273 | type DenyRuleRequest struct {
274 | Rule string `json:"rule" binding:"required"`
275 | Comment string `json:"comment"`
276 | }
277 | authorized.POST("/rules/deny", func(c *gin.Context) {
278 | var req DenyRuleRequest
279 | if err := c.ShouldBindJSON(&req); err != nil {
280 | c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
281 | return
282 | }
283 | if err := DenyUFWPort(req.Rule, req.Comment); err != nil {
284 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add deny rule", "details": err.Error()})
285 | return
286 | }
287 | c.JSON(http.StatusOK, gin.H{"message": "Deny rule added successfully", "rule": req.Rule, "comment": req.Comment})
288 | })
289 |
290 | authorized.DELETE("/rules/delete/:number", func(c *gin.Context) {
291 | ruleNumber := c.Param("number")
292 | if ruleNumber == "" {
293 | c.JSON(http.StatusBadRequest, gin.H{"error": "Rule number parameter is required"})
294 | return
295 | }
296 | if err := DeleteUFWByNumber(ruleNumber); err != nil {
297 | if strings.Contains(err.Error(), "not found") {
298 | c.JSON(http.StatusNotFound, gin.H{"error": "Rule not found", "details": err.Error()})
299 | } else {
300 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete rule", "details": err.Error()})
301 | }
302 | return
303 | }
304 | c.JSON(http.StatusOK, gin.H{"message": "Rule deleted successfully", "rule_number": ruleNumber})
305 | })
306 |
307 | authorized.POST("/enable", func(c *gin.Context) {
308 | log.Println("Attempting to enable UFW via API endpoint...")
309 | if err := EnableUFW(); err != nil {
310 | log.Printf("Error enabling UFW via API: %v", err)
311 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable UFW", "details": err.Error()})
312 | return
313 | }
314 | log.Println("UFW enabled successfully via API.")
315 | c.JSON(http.StatusOK, gin.H{"message": "UFW enabled successfully"})
316 | })
317 |
318 | authorized.POST("/disable", func(c *gin.Context) {
319 | if err := DisableUFW(); err != nil {
320 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable UFW", "details": err.Error()})
321 | return
322 | }
323 | c.JSON(http.StatusOK, gin.H{"message": "UFW disabled successfully (or was already inactive)"})
324 | })
325 |
326 | type IPRuleRequest struct {
327 | IPAddress string `json:"ip_address" binding:"required"`
328 | PortProtocol string `json:"port_protocol"`
329 | Comment string `json:"comment"`
330 | }
331 | authorized.POST("/rules/allow/ip", func(c *gin.Context) {
332 | var req IPRuleRequest
333 | if err := c.ShouldBindJSON(&req); err != nil {
334 | c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
335 | return
336 | }
337 | if err := AllowUFWFromIP(req.IPAddress, req.PortProtocol, req.Comment); err != nil {
338 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add allow rule from IP", "details": err.Error()})
339 | return
340 | }
341 | c.JSON(http.StatusOK, gin.H{"message": "Allow rule from IP added successfully", "ip_address": req.IPAddress, "port_protocol": req.PortProtocol, "comment": req.Comment})
342 | })
343 | authorized.POST("/rules/deny/ip", func(c *gin.Context) {
344 | var req IPRuleRequest
345 | if err := c.ShouldBindJSON(&req); err != nil {
346 | c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
347 | return
348 | }
349 | if err := DenyUFWFromIP(req.IPAddress, req.PortProtocol, req.Comment); err != nil {
350 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add deny rule from IP", "details": err.Error()})
351 | return
352 | }
353 | c.JSON(http.StatusOK, gin.H{"message": "Deny rule from IP added successfully", "ip_address": req.IPAddress, "port_protocol": req.PortProtocol, "comment": req.Comment})
354 | })
355 |
356 | type RouteAllowRuleRequest struct {
357 | Protocol string `json:"protocol"`
358 | FromIP string `json:"from_ip"`
359 | ToIP string `json:"to_ip"`
360 | Port string `json:"port"`
361 | Comment string `json:"comment"`
362 | }
363 | authorized.POST("/rules/route/allow", func(c *gin.Context) {
364 | var req RouteAllowRuleRequest
365 | if err := c.ShouldBindJSON(&req); err != nil {
366 | c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
367 | return
368 | }
369 | if req.Protocol == "" && req.Port == "" {
370 | c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: Protocol or Port must be specified for a route rule."})
371 | return
372 | }
373 | if err := RouteAllowUFW(req.Protocol, req.FromIP, req.ToIP, req.Port, req.Comment); err != nil {
374 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add route allow rule", "details": err.Error()})
375 | return
376 | }
377 | c.JSON(http.StatusOK, gin.H{
378 | "message": "Route allow rule added successfully",
379 | "protocol": req.Protocol,
380 | "from_ip": req.FromIP,
381 | "to_ip": req.ToIP,
382 | "port": req.Port,
383 | "comment": req.Comment,
384 | })
385 | })
386 | }
387 |
388 | port := os.Getenv("PORT")
389 | if port == "" {
390 | port = "30737"
391 | }
392 | apiRule := port + "/tcp"
393 |
394 | log.Printf("Attempting to add allow rule for API port %s during startup...", apiRule)
395 | if startupErr := AllowUFWPort(apiRule, ""); startupErr != nil {
396 | if strings.Contains(startupErr.Error(), "Skipping adding existing rule") {
397 | log.Printf("Rule for API port '%s' already exists or skipping message detected.", apiRule)
398 | } else {
399 | log.Printf("WARNING: Error adding allow rule for API port '%s' during startup: %v. Ensure the server is run with sudo if needed.", apiRule, startupErr)
400 | }
401 | } else {
402 | log.Printf("Successfully added or ensured allow rule for API port: %s", apiRule)
403 | }
404 |
405 | log.Printf("Starting server on port %s", port)
406 |
407 | // Check for custom TLS certificate paths
408 | certPath := os.Getenv("TLS_CERT_PATH")
409 | keyPath := os.Getenv("TLS_KEY_PATH")
410 |
411 | if certPath != "" && keyPath != "" {
412 | // Validate custom certificate files exist
413 | if _, err := os.Stat(certPath); os.IsNotExist(err) {
414 | log.Fatalf("FATAL: Custom certificate file not found: %s", certPath)
415 | }
416 | if _, err := os.Stat(keyPath); os.IsNotExist(err) {
417 | log.Fatalf("FATAL: Custom private key file not found: %s", keyPath)
418 | }
419 | log.Printf("Using custom TLS certificate: %s and key: %s", certPath, keyPath)
420 | } else {
421 | // Use self-signed certificate
422 | certPath = certFileName
423 | keyPath = keyFileName
424 | if err := ensureSelfSignedCert(certPath, keyPath); err != nil {
425 | log.Fatalf("FATAL: Failed to ensure self-signed certificate: %v", err)
426 | }
427 | }
428 |
429 | log.Printf("Attempting to start HTTPS server on port %s using %s and %s", port, certPath, keyPath)
430 | if err := router.RunTLS(":"+port, certPath, keyPath); err != nil {
431 | log.Fatalf("FATAL: Failed to start HTTPS server: %v", err)
432 | }
433 | }
434 |
--------------------------------------------------------------------------------