├── frontend ├── .yarnrc.yml ├── app │ ├── favicon.ico │ ├── page.tsx │ ├── layout.tsx │ └── globals.css ├── public │ ├── logo.png │ ├── favicon.png │ └── logo-width.png ├── postcss.config.mjs ├── lib │ ├── types.ts │ ├── utils.ts │ └── api.ts ├── .dockerignore ├── internal │ ├── models │ │ └── backend.go │ ├── handlers │ │ ├── response.go │ │ ├── auth.go │ │ ├── backends.go │ │ └── firewall.go │ ├── middleware │ │ └── auth.go │ ├── services │ │ ├── auth │ │ │ └── service.go │ │ └── relay │ │ │ └── client.go │ ├── config │ │ └── config.go │ ├── app │ │ └── server.go │ └── storage │ │ └── sqlite.go ├── next.config.ts ├── entrypoint.sh ├── eslint.config.mjs ├── components.json ├── components │ ├── ui │ │ ├── sonner.tsx │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── checkbox.tsx │ │ ├── radio-group.tsx │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── table.tsx │ │ ├── dialog.tsx │ │ ├── alert-dialog.tsx │ │ └── select.tsx │ ├── Footer.tsx │ ├── StatusControlCard.tsx │ ├── DeleteBackendDialog.tsx │ ├── DeleteRuleDialog.tsx │ ├── BackendStatus.tsx │ ├── RulesTableCard.tsx │ ├── PasswordAuth.tsx │ └── AddBackendDialog.tsx ├── .gitignore ├── tsconfig.json ├── package.json ├── Dockerfile ├── cmd │ └── frontend-server │ │ └── main.go ├── go.mod ├── README.md └── go.sum ├── backend ├── .env.sample ├── go.mod ├── go.sum ├── README.md ├── ufw.go └── main.go ├── docker-compose.yml ├── .env.sample ├── LICENSE ├── .github └── workflows │ ├── frontend-docker.yml │ └── backend-ci.yml └── README.md /frontend/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gouryella/UFW-Panel/HEAD/frontend/app/favicon.ico -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gouryella/UFW-Panel/HEAD/frontend/public/logo.png -------------------------------------------------------------------------------- /frontend/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gouryella/UFW-Panel/HEAD/frontend/public/favicon.png -------------------------------------------------------------------------------- /frontend/public/logo-width.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gouryella/UFW-Panel/HEAD/frontend/public/logo-width.png -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /frontend/lib/types.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface BackendConfig { 3 | id: string; 4 | name: string; 5 | url: string; 6 | apiKey?: string; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /backend/.env.sample: -------------------------------------------------------------------------------- 1 | # UFW Backend Configuration 2 | UFW_API_KEY=123456 3 | PORT=30737 4 | CORS_ALLOWED_ORIGINS=http://localhost:3000, https://google.com 5 | MAX_FAILS=5 6 | UFW_TIMEOUT_SEC=5 7 | UFW_SUDO=1 8 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git 8 | .gitignore 9 | # Add any other files or directories that are not needed for the build 10 | -------------------------------------------------------------------------------- /frontend/internal/models/backend.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Backend struct { 4 | ID string `json:"id"` 5 | Name string `json:"name"` 6 | URL string `json:"url"` 7 | APIKey string `json:"apiKey,omitempty"` 8 | } 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ufw-panel-frontend: 3 | image: gouryella/ufw-panel:latest 4 | container_name: ufw-panel-frontend 5 | ports: 6 | - "30737:8080" 7 | volumes: 8 | - ./data/database:/app/database 9 | env_file: 10 | - .env 11 | restart: unless-stopped -------------------------------------------------------------------------------- /frontend/internal/handlers/response.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func writeError(c *gin.Context, status int, message string, details any) { 6 | payload := gin.H{"error": message} 7 | if details != nil { 8 | payload["details"] = details 9 | } 10 | c.JSON(status, payload) 11 | } 12 | -------------------------------------------------------------------------------- /frontend/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | eslint: { 5 | ignoreDuringBuilds: true, 6 | }, 7 | output: "export", 8 | trailingSlash: true, 9 | images: { 10 | unoptimized: true, 11 | }, 12 | productionBrowserSourceMaps: false, 13 | }; 14 | 15 | export default nextConfig; 16 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | import UfwControlPanel from "@/components/UfwControlPanel"; 2 | import Footer from "@/components/Footer"; 3 | 4 | export default function Home() { 5 | return ( 6 |
7 |
8 | 9 |
10 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /frontend/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Ensure the /app/database directory exists (it should be the mount point) 5 | mkdir -p /app/database 6 | 7 | # Make the database directory and its contents world-writable INSIDE the container. 8 | # This is a common workaround for volume permission issues. 9 | # This runs as root before the CMD switches to the 'nextjs' user. 10 | chmod -R 777 /app/database 11 | 12 | # Hand off to the CMD 13 | exec "$@" 14 | -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /frontend/lib/api.ts: -------------------------------------------------------------------------------- 1 | const envBase = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") ?? ""; 2 | 3 | export const resolveApiUrl = (path: string): string => { 4 | const base = envBase || (typeof window !== "undefined" ? window.location.origin : ""); 5 | if (!base) { 6 | return path; 7 | } 8 | const normalizedPath = path.startsWith("/") ? path : `/${path}`; 9 | const url = new URL(normalizedPath, base); 10 | return url.toString(); 11 | }; 12 | 13 | export const apiBase = envBase; 14 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /frontend/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /database 5 | /node_modules 6 | /.pnp 7 | .pnp.* 8 | .yarn/* 9 | !.yarn/patches 10 | !.yarn/plugins 11 | !.yarn/releases 12 | !.yarn/versions 13 | 14 | # testing 15 | /coverage 16 | 17 | # next.js 18 | /.next/ 19 | /out/ 20 | 21 | # production 22 | /build 23 | 24 | # misc 25 | .DS_Store 26 | *.pem 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .pnpm-debug.log* 33 | 34 | # env files (can opt-in for committing if needed) 35 | .env* 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | .env.local 44 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /frontend/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /frontend/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | export default function Footer() { 2 | return ( 3 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /frontend/internal/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "ufwpanel/frontend/internal/config" 9 | "ufwpanel/frontend/internal/services/auth" 10 | ) 11 | 12 | func RequireAuth(authSvc *auth.Service) gin.HandlerFunc { 13 | return func(c *gin.Context) { 14 | token, err := c.Cookie(config.CookieName) 15 | if err != nil || token == "" { 16 | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ 17 | "authenticated": false, 18 | "error": "Unauthorized.", 19 | }) 20 | return 21 | } 22 | 23 | if err := authSvc.VerifyToken(token); err != nil { 24 | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ 25 | "authenticated": false, 26 | "error": "Session expired or invalid.", 27 | }) 28 | return 29 | } 30 | 31 | c.Next() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Frontend gateway configuration 2 | # Copy this file to .env (or export the variables manually) before running 3 | 4 | # Password required when posting to /api/auth 5 | AUTH_PASSWORD=changeme 6 | 7 | # HMAC secret used to sign the session cookie (must be kept private) 8 | JWT_SECRET=replace-this-with-a-long-random-string 9 | 10 | # Optional token lifetime; accepts values like 1d, 12h, 30m, or seconds 11 | JWT_EXPIRATION=1d 12 | 13 | # TCP port for the Go server to listen on (defaults to 8080 if omitted) 14 | PORT=8080 15 | 16 | # Location of the exported Next.js assets served by the Go gateway 17 | FRONTEND_DIST_DIR=./out 18 | 19 | # Path to the SQLite database that stores backend connection details 20 | FRONTEND_DB_PATH=./database/ufw-webui.db 21 | 22 | # Optional comma-separated list of allowed origins for browser clients. 23 | # Leave unset to allow all origins. 24 | # FRONTEND_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 25 | 26 | # When running `yarn dev`, set this so the browser calls the Go API server 27 | NEXT_PUBLIC_API_BASE=http://localhost:8080 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Gouryella 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "UFW-Panel", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next dev --turbopack", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-alert-dialog": "^1.1.13", 13 | "@radix-ui/react-checkbox": "^1.3.1", 14 | "@radix-ui/react-dialog": "^1.1.13", 15 | "@radix-ui/react-label": "^2.1.6", 16 | "@radix-ui/react-radio-group": "^1.3.6", 17 | "@radix-ui/react-select": "^2.2.4", 18 | "@radix-ui/react-slot": "^1.2.2", 19 | "@types/uuid": "^10.0.0", 20 | "class-variance-authority": "^0.7.1", 21 | "clsx": "^2.1.1", 22 | "lucide-react": "^0.507.0", 23 | "motion": "^12.23.12", 24 | "next": "15.3.1", 25 | "next-themes": "^0.4.6", 26 | "react": "^19.0.0", 27 | "react-dom": "^19.0.0", 28 | "sonner": "^2.0.3", 29 | "tailwind-merge": "^3.2.0", 30 | "uuid": "^11.1.0" 31 | }, 32 | "devDependencies": { 33 | "@eslint/eslintrc": "^3", 34 | "@tailwindcss/postcss": "^4", 35 | "@types/node": "^20", 36 | "@types/react": "^19", 37 | "@types/react-dom": "^19", 38 | "eslint": "^9", 39 | "eslint-config-next": "15.3.1", 40 | "tailwindcss": "^4", 41 | "tw-animate-css": "^1.2.9", 42 | "typescript": "^5" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { CheckIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Checkbox({ 10 | className, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export { Checkbox } 33 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import { cn } from "@/lib/utils" 4 | import { Toaster } from "@/components/ui/sonner" 5 | 6 | export const metadata: Metadata = { 7 | title: "UFW Panel", 8 | }; 9 | 10 | export default function RootLayout({ 11 | children, 12 | }: Readonly<{ 13 | children: React.ReactNode; 14 | }>) { 15 | return ( 16 | 17 | 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | {children} 30 |
31 |
32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/frontend-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Frontend Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: [ 'v*' ] 8 | paths: 9 | - 'frontend/**' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build-and-push: 14 | name: Build frontend Docker image 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v3 27 | with: 28 | platforms: arm64 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | 33 | - name: Log in to Docker Hub 34 | uses: docker/login-action@v3 35 | with: 36 | username: ${{ secrets.DOCKERHUB_USERNAME }} 37 | password: ${{ secrets.DOCKERHUB_TOKEN }} 38 | 39 | - name: Build and push image (multi-arch) 40 | uses: docker/build-push-action@v5 41 | env: 42 | DOCKER_BUILDKIT: 1 43 | BUILDKIT_PROGRESS: plain 44 | with: 45 | context: ./frontend 46 | file: ./frontend/Dockerfile 47 | push: true 48 | platforms: linux/amd64,linux/arm64 49 | tags: | 50 | gouryella/ufw-panel:latest 51 | gouryella/ufw-panel:${{ github.sha }} 52 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: install Node dependencies 2 | FROM --platform=$BUILDPLATFORM node:22-alpine AS node-deps 3 | RUN apk add --no-cache libc6-compat python3 build-base 4 | WORKDIR /app 5 | RUN corepack enable 6 | COPY package.json yarn.lock .yarnrc.yml ./ 7 | RUN yarn config set networkTimeout 600000 -g \ 8 | && yarn config set networkConcurrency 1 -g \ 9 | && yarn install 10 | 11 | # Stage 2: build static frontend 12 | FROM --platform=$BUILDPLATFORM node:22-alpine AS node-build 13 | WORKDIR /app 14 | RUN corepack enable 15 | COPY --from=node-deps /app/node_modules ./node_modules 16 | COPY . . 17 | RUN yarn build 18 | 19 | # Stage 3: build Go API server 20 | FROM golang:1.24 AS go-build 21 | WORKDIR /app 22 | COPY go.mod go.sum ./ 23 | RUN go mod download 24 | COPY internal ./internal 25 | COPY cmd ./cmd 26 | ARG TARGETOS 27 | ARG TARGETARCH 28 | RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o frontend-server ./cmd/frontend-server 29 | 30 | # Final runtime image 31 | FROM alpine:3.20 32 | WORKDIR /app 33 | RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates 34 | COPY --from=go-build /app/frontend-server ./frontend-server 35 | COPY --from=node-build /app/out ./out 36 | COPY entrypoint.sh /usr/local/bin/ 37 | RUN chmod +x /usr/local/bin/entrypoint.sh 38 | 39 | ENV PORT=8080 40 | ENV FRONTEND_DIST_DIR=/app/out 41 | 42 | ENTRYPOINT ["entrypoint.sh"] 43 | CMD ["./frontend-server"] 44 | 45 | EXPOSE 8080 46 | -------------------------------------------------------------------------------- /frontend/internal/services/auth/service.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/golang-jwt/jwt/v5" 8 | 9 | "ufwpanel/frontend/internal/config" 10 | ) 11 | 12 | type Service struct { 13 | secret []byte 14 | expiresIn time.Duration 15 | password string 16 | } 17 | 18 | func NewService(cfg *config.Config) *Service { 19 | return &Service{ 20 | secret: cfg.JWTSecret, 21 | expiresIn: cfg.JWTExpiresIn, 22 | password: cfg.AuthPassword, 23 | } 24 | } 25 | 26 | func (s *Service) Authenticate(password string) bool { 27 | if s.password == "" { 28 | return password == "" 29 | } 30 | return password == s.password 31 | } 32 | 33 | func (s *Service) IssueToken() (string, time.Time, error) { 34 | claims := jwt.RegisteredClaims{ 35 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.expiresIn)), 36 | IssuedAt: jwt.NewNumericDate(time.Now()), 37 | } 38 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 39 | signed, err := token.SignedString(s.secret) 40 | if err != nil { 41 | return "", time.Time{}, err 42 | } 43 | return signed, claims.ExpiresAt.Time, nil 44 | } 45 | 46 | func (s *Service) VerifyToken(token string) error { 47 | if token == "" { 48 | return errors.New("empty token") 49 | } 50 | _, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { 51 | if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { 52 | return nil, errors.New("unexpected signing method") 53 | } 54 | return s.secret, nil 55 | }) 56 | return err 57 | } 58 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module ufw-backend 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/gin-contrib/cors v1.7.5 7 | github.com/gin-gonic/gin v1.10.0 8 | github.com/joho/godotenv v1.5.1 9 | ) 10 | 11 | require ( 12 | github.com/bytedance/sonic v1.13.2 // indirect 13 | github.com/bytedance/sonic/loader v0.2.4 // indirect 14 | github.com/cloudwego/base64x v0.1.5 // indirect 15 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 16 | github.com/gin-contrib/sse v1.1.0 // indirect 17 | github.com/go-playground/locales v0.14.1 // indirect 18 | github.com/go-playground/universal-translator v0.18.1 // indirect 19 | github.com/go-playground/validator/v10 v10.26.0 // indirect 20 | github.com/goccy/go-json v0.10.5 // indirect 21 | github.com/json-iterator/go v1.1.12 // indirect 22 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 23 | github.com/kr/text v0.2.0 // indirect 24 | github.com/leodido/go-urn v1.4.0 // indirect 25 | github.com/mattn/go-isatty v0.0.20 // indirect 26 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 27 | github.com/modern-go/reflect2 v1.0.2 // indirect 28 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 29 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 30 | github.com/ugorji/go/codec v1.2.12 // indirect 31 | golang.org/x/arch v0.17.0 // indirect 32 | golang.org/x/crypto v0.38.0 // indirect 33 | golang.org/x/net v0.40.0 // indirect 34 | golang.org/x/sys v0.33.0 // indirect 35 | golang.org/x/text v0.25.0 // indirect 36 | google.golang.org/protobuf v1.36.6 // indirect 37 | gopkg.in/yaml.v3 v3.0.1 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /frontend/cmd/frontend-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/joho/godotenv" 13 | 14 | "ufwpanel/frontend/internal/app" 15 | "ufwpanel/frontend/internal/config" 16 | ) 17 | 18 | func main() { 19 | if err := godotenv.Load(); err != nil { 20 | log.Printf("warning: %v", err) 21 | } 22 | 23 | cfg, err := config.Load() 24 | if err != nil { 25 | log.Fatalf("failed to load configuration: %v", err) 26 | } 27 | 28 | server, err := app.NewServer(cfg) 29 | if err != nil { 30 | log.Fatalf("failed to initialise server: %v", err) 31 | } 32 | defer func() { 33 | if err := server.Close(); err != nil { 34 | log.Printf("error closing server resources: %v", err) 35 | } 36 | }() 37 | 38 | httpServer := &http.Server{ 39 | Addr: cfg.ListenAddr, 40 | Handler: server.Engine(), 41 | } 42 | 43 | go func() { 44 | log.Printf("frontend server listening on %s", cfg.ListenAddr) 45 | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 46 | log.Fatalf("server error: %v", err) 47 | } 48 | }() 49 | 50 | sigCh := make(chan os.Signal, 1) 51 | signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 52 | <-sigCh 53 | 54 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 55 | defer cancel() 56 | 57 | if err := httpServer.Shutdown(ctx); err != nil { 58 | log.Printf("graceful shutdown failed: %v", err) 59 | } else { 60 | log.Println("server stopped") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" 5 | import { CircleIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function RadioGroup({ 10 | className, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 19 | ) 20 | } 21 | 22 | function RadioGroupItem({ 23 | className, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 35 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export { RadioGroup, RadioGroupItem } 46 | -------------------------------------------------------------------------------- /frontend/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-card text-card-foreground", 12 | destructive: 13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<"div"> & VariantProps) { 27 | return ( 28 |
34 | ) 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 | return ( 39 |
47 | ) 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<"div">) { 54 | return ( 55 |
63 | ) 64 | } 65 | 66 | export { Alert, AlertTitle, AlertDescription } 67 | -------------------------------------------------------------------------------- /frontend/internal/services/relay/client.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | "time" 14 | 15 | "ufwpanel/frontend/internal/models" 16 | ) 17 | 18 | type Client struct { 19 | http *http.Client 20 | } 21 | 22 | func NewClient(timeout time.Duration) *Client { 23 | transport := &http.Transport{ 24 | Proxy: http.ProxyFromEnvironment, 25 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 26 | } 27 | 28 | return &Client{ 29 | http: &http.Client{ 30 | Timeout: timeout, 31 | Transport: transport, 32 | }, 33 | } 34 | } 35 | 36 | func (c *Client) Forward(ctx context.Context, backend *models.Backend, method, path string, body any) (*http.Response, error) { 37 | if backend == nil { 38 | return nil, fmt.Errorf("missing backend configuration") 39 | } 40 | 41 | fullURL, err := joinURL(backend.URL, path) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | var reader io.ReadCloser 47 | if body != nil { 48 | payload, err := json.Marshal(body) 49 | if err != nil { 50 | return nil, fmt.Errorf("marshal payload: %w", err) 51 | } 52 | reader = io.NopCloser(bytes.NewReader(payload)) 53 | } else { 54 | reader = http.NoBody 55 | } 56 | 57 | req, err := http.NewRequestWithContext(ctx, method, fullURL, reader) 58 | if err != nil { 59 | return nil, fmt.Errorf("create request: %w", err) 60 | } 61 | 62 | req.Header.Set("X-API-KEY", backend.APIKey) 63 | if body != nil { 64 | req.Header.Set("Content-Type", "application/json") 65 | } 66 | 67 | return c.http.Do(req) 68 | } 69 | 70 | func joinURL(base, path string) (string, error) { 71 | if base == "" { 72 | return "", fmt.Errorf("empty backend URL") 73 | } 74 | baseURL, err := url.Parse(base) 75 | if err != nil { 76 | return "", fmt.Errorf("invalid backend URL: %w", err) 77 | } 78 | joined, err := url.Parse(path) 79 | if err != nil { 80 | return "", fmt.Errorf("invalid path: %w", err) 81 | } 82 | if joined.Scheme != "" || joined.Host != "" { 83 | return joined.String(), nil 84 | } 85 | baseURL.Path = strings.TrimRight(baseURL.Path, "/") + path 86 | return baseURL.String(), nil 87 | } 88 | -------------------------------------------------------------------------------- /frontend/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /frontend/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /frontend/go.mod: -------------------------------------------------------------------------------- 1 | module ufwpanel/frontend 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.7 6 | 7 | require ( 8 | github.com/gin-contrib/cors v1.7.6 9 | github.com/gin-gonic/gin v1.11.0 10 | github.com/golang-jwt/jwt/v5 v5.3.0 11 | github.com/google/uuid v1.6.0 12 | github.com/joho/godotenv v1.5.1 13 | modernc.org/sqlite v1.39.0 14 | ) 15 | 16 | require ( 17 | github.com/bytedance/gopkg v0.1.3 // indirect 18 | github.com/bytedance/sonic v1.14.1 // indirect 19 | github.com/bytedance/sonic/loader v0.3.0 // indirect 20 | github.com/cloudwego/base64x v0.1.6 // indirect 21 | github.com/dustin/go-humanize v1.0.1 // indirect 22 | github.com/gabriel-vasile/mimetype v1.4.10 // indirect 23 | github.com/gin-contrib/sse v1.1.0 // indirect 24 | github.com/go-playground/locales v0.14.1 // indirect 25 | github.com/go-playground/universal-translator v0.18.1 // indirect 26 | github.com/go-playground/validator/v10 v10.27.0 // indirect 27 | github.com/goccy/go-json v0.10.5 // indirect 28 | github.com/goccy/go-yaml v1.18.0 // indirect 29 | github.com/json-iterator/go v1.1.12 // indirect 30 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 31 | github.com/leodido/go-urn v1.4.0 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 34 | github.com/modern-go/reflect2 v1.0.2 // indirect 35 | github.com/ncruces/go-strftime v0.1.9 // indirect 36 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 37 | github.com/quic-go/qpack v0.5.1 // indirect 38 | github.com/quic-go/quic-go v0.54.0 // indirect 39 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 40 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 41 | github.com/ugorji/go/codec v1.3.0 // indirect 42 | go.uber.org/mock v0.5.0 // indirect 43 | golang.org/x/arch v0.21.0 // indirect 44 | golang.org/x/crypto v0.42.0 // indirect 45 | golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect 46 | golang.org/x/mod v0.28.0 // indirect 47 | golang.org/x/net v0.44.0 // indirect 48 | golang.org/x/sync v0.17.0 // indirect 49 | golang.org/x/sys v0.36.0 // indirect 50 | golang.org/x/text v0.29.0 // indirect 51 | golang.org/x/tools v0.37.0 // indirect 52 | google.golang.org/protobuf v1.36.9 // indirect 53 | modernc.org/libc v1.66.9 // indirect 54 | modernc.org/mathutil v1.7.1 // indirect 55 | modernc.org/memory v1.11.0 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /frontend/internal/handlers/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "ufwpanel/frontend/internal/config" 10 | "ufwpanel/frontend/internal/services/auth" 11 | ) 12 | 13 | type AuthHandler struct { 14 | auth *auth.Service 15 | } 16 | 17 | func NewAuthHandler(authSvc *auth.Service) *AuthHandler { 18 | return &AuthHandler{auth: authSvc} 19 | } 20 | 21 | func (h *AuthHandler) Register(rg *gin.RouterGroup) { 22 | rg.GET("/auth", h.getAuth) 23 | rg.POST("/auth", h.postAuth) 24 | rg.POST("/auth/logout", h.logout) 25 | } 26 | 27 | func (h *AuthHandler) getAuth(c *gin.Context) { 28 | token, err := c.Cookie(config.CookieName) 29 | if err != nil || token == "" { 30 | c.JSON(http.StatusOK, gin.H{"authenticated": false}) 31 | return 32 | } 33 | 34 | if err := h.auth.VerifyToken(token); err != nil { 35 | expireCookie(c) 36 | status := http.StatusUnauthorized 37 | c.JSON(status, gin.H{ 38 | "authenticated": false, 39 | "error": "Session expired or invalid.", 40 | }) 41 | return 42 | } 43 | 44 | c.JSON(http.StatusOK, gin.H{"authenticated": true}) 45 | } 46 | 47 | func (h *AuthHandler) postAuth(c *gin.Context) { 48 | type request struct { 49 | Password string `json:"password"` 50 | } 51 | var body request 52 | if err := c.ShouldBindJSON(&body); err != nil { 53 | writeError(c, http.StatusBadRequest, "Password not provided.", nil) 54 | return 55 | } 56 | 57 | if h.auth == nil { 58 | writeError(c, http.StatusInternalServerError, "Server configuration error.", nil) 59 | return 60 | } 61 | 62 | if !h.auth.Authenticate(body.Password) { 63 | writeError(c, http.StatusUnauthorized, "Incorrect password.", nil) 64 | return 65 | } 66 | 67 | token, expiresAt, err := h.auth.IssueToken() 68 | if err != nil { 69 | writeError(c, http.StatusInternalServerError, "Failed to issue auth token.", err.Error()) 70 | return 71 | } 72 | 73 | secure := gin.Mode() == gin.ReleaseMode 74 | 75 | http.SetCookie(c.Writer, &http.Cookie{ 76 | Name: config.CookieName, 77 | Value: token, 78 | Path: "/", 79 | Expires: expiresAt, 80 | HttpOnly: true, 81 | Secure: secure, 82 | SameSite: http.SameSiteLaxMode, 83 | }) 84 | 85 | c.JSON(http.StatusOK, gin.H{"authenticated": true}) 86 | } 87 | 88 | func (h *AuthHandler) logout(c *gin.Context) { 89 | expireCookie(c) 90 | c.JSON(http.StatusOK, gin.H{"message": "Logout successful"}) 91 | } 92 | 93 | func expireCookie(c *gin.Context) { 94 | http.SetCookie(c.Writer, &http.Cookie{ 95 | Name: config.CookieName, 96 | Value: "", 97 | Path: "/", 98 | Expires: time.Unix(0, 0), 99 | HttpOnly: true, 100 | Secure: gin.Mode() == gin.ReleaseMode, 101 | SameSite: http.SameSiteStrictMode, 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # UFW Panel Frontend 2 | 3 | This package now consists of two pieces that ship together: 4 | 5 | - A static Next.js interface exported into the `out/` directory. 6 | - A Go gateway (`cmd/frontend-server`) that replaces the former Next.js API routes and serves both the static assets and the proxy endpoints consumed by the UI. 7 | 8 | ## Prerequisites 9 | 10 | - Node.js 22+ (with Corepack/Yarn enabled) 11 | - Go 1.23+ 12 | 13 | ## Development workflow 14 | 15 | 1. Install dependencies once: 16 | ```bash 17 | yarn install 18 | ``` 19 | 2. Export the UI whenever you need fresh static assets: 20 | ```bash 21 | yarn build 22 | ``` 23 | The build writes to `out/` and can be watched/served by the Go server immediately. 24 | 3. Start the Go server (it will serve `/api/*` and the static front-end). The process automatically loads variables from a local `.env` file if present: 25 | ```bash 26 | AUTH_PASSWORD=changeme \ 27 | JWT_SECRET=some-long-secret \ 28 | go run ./cmd/frontend-server 29 | ``` 30 | 4. Optional: when running `yarn dev` for rapid UI iteration, point the browser calls to the Go server by exporting `NEXT_PUBLIC_API_BASE=http://localhost:8080` before starting the dev server. 31 | 32 | Environment variables recognised by the server: 33 | 34 | - `AUTH_PASSWORD` – password expected by the `/api/auth` endpoint (required for login). 35 | - `JWT_SECRET` – HMAC secret used to sign the session cookie (required). 36 | - `JWT_EXPIRATION` – optional TTL expression (`1d`, `12h`, etc.), default `1d`. 37 | - `PORT` – listening port for the Go server, default `8080`. 38 | - `FRONTEND_DIST_DIR` – override location of the exported Next.js assets if you are not using the default `./out`. 39 | - `FRONTEND_DB_PATH` – optional path to the SQLite file (`./database/ufw-webui.db` by default). 40 | - `FRONTEND_ALLOWED_ORIGINS` – optional comma-separated list of origins permitted for cross-site requests with cookies; leave unset to allow requests from any origin. 41 | 42 | ## Production build 43 | 44 | The provided Dockerfile compiles the Go gateway and exports the UI in a multi-stage build: 45 | 46 | ```bash 47 | docker build -t ufw-panel-frontend ./frontend 48 | ``` 49 | 50 | The resulting image exposes port `8080` and expects the same environment variables listed above at runtime. 51 | 52 | ## Testing 53 | 54 | Run the following commands from the `frontend/` directory to make sure the project still builds cleanly: 55 | 56 | ```bash 57 | # Compile the Go gateway 58 | go build ./... 59 | 60 | # Lint the React client 61 | yarn lint 62 | 63 | # Regenerate the static export used by the Go server 64 | yarn build 65 | 66 | # (Optional) Smoke-test the full stack locally 67 | AUTH_PASSWORD=changeme \ 68 | JWT_SECRET=some-long-secret \ 69 | go run ./cmd/frontend-server 70 | ``` 71 | 72 | If you have additional Go or React tests in the future, add them alongside these commands so they remain easy to discover. 73 | -------------------------------------------------------------------------------- /frontend/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Table({ className, ...props }: React.ComponentProps<"table">) { 8 | return ( 9 |
13 | 18 | 19 | ) 20 | } 21 | 22 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 23 | return ( 24 | 29 | ) 30 | } 31 | 32 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 33 | return ( 34 | 39 | ) 40 | } 41 | 42 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { 43 | return ( 44 | 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 | 65 | 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 | 109 |
110 | 111 |
112 |
113 |
118 | {initialLoading ? "—" : onlineCount ?? "—"} 119 |
120 |
{availability}
121 |
122 |
123 |
127 |
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 | 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 | 121 | 122 | 123 | ); 124 | })} 125 | 126 |
127 | {totalPages > 1 && ( 128 |
129 |
130 | 139 | 140 | Page {currentPage} of {totalPages} 141 | 142 | 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 |
88 |
89 |
90 |
91 |
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 |
151 |
152 |
153 | 154 | 155 |
156 | UFW Panel 157 |
158 | Welcome back 159 |

160 | Use your backend password to unlock the control panel. 161 |

162 |
163 | 164 |
165 | 166 | {localError && ( 167 | 168 | 169 | 170 | 171 | {localError} 172 | 173 | 174 | 175 | )} 176 | 177 |
178 | 181 |
182 | { 187 | setPassword(e.target.value); 188 | setLocalError(null); 189 | clearError(); 190 | }} 191 | placeholder="Enter your password" 192 | required 193 | disabled={isLoading} 194 | className="pr-11 border-white/15 bg-white/90 text-slate-950 placeholder:text-slate-500 focus:border-transparent focus:ring-2 focus:ring-cyan-400/70" 195 | autoFocus 196 | /> 197 | 206 |
207 | {capsOn && ( 208 |

Caps Lock is on.

209 | )} 210 |

{tip}

211 |
212 |
213 | 214 | 215 | 228 | 229 |
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 | 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 | 222 | 231 |
232 | 233 |
234 | 237 |
238 | setHost(e.target.value)} 242 | placeholder="e.g., 192.168.1.100" 243 | autoComplete="off" 244 | required 245 | className={cn( 246 | "h-11 rounded-2xl border-white/15 bg-white/10 text-sm font-medium text-slate-100 placeholder:text-slate-400/80 shadow-inner", 247 | "focus-visible:border-indigo-400/70 focus-visible:ring-indigo-400/30" 248 | )} 249 | /> 250 | setPort(e.target.value.replace(/[^0-9]/g, ""))} 254 | placeholder="Port" 255 | inputMode="numeric" 256 | pattern="[0-9]*" 257 | required 258 | className={cn( 259 | "h-11 w-28 rounded-2xl border-white/15 bg-white/10 text-center text-sm font-semibold text-slate-100 placeholder:text-slate-400/70 shadow-inner", 260 | "focus-visible:border-indigo-400/70 focus-visible:ring-indigo-400/30" 261 | )} 262 | /> 263 |
264 |
265 |
266 | 267 |
268 | 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 | 294 | 295 | 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 | --------------------------------------------------------------------------------