first commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
go.mod
|
||||
go.sum
|
||||
config.json*
|
||||
65
README.md
Normal file
65
README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
### Unifi Custom DNS Blocklist Manager: Project Summary and Web Application Template
|
||||
|
||||
#### Overview
|
||||
This project is inspired by a Bash script designed for Unifi network environments (or similar router/DNS setups) to manage a custom DNS blocklist for ad-blocking, malware prevention, phishing protection, and crypto-mining domain blocking. The original script monitors the CoreDNS process (a lightweight DNS server often used in containerized or edge router setups) for PID changes, indicating a restart or external modification. Upon detection, it automatically updates or merges a domain blocklist file (`/run/utm/domain_list/domainlist_0.list`) by fetching and processing lists from configurable URLs, applying removal rules from a whitelist file (`/run/utm/domain_list/domainlist_1.list`), cleaning invalid entries, and ensuring the active blocklist remains effective. It includes time-based throttling (e.g., full updates only every 3 days unless forced) to avoid excessive network fetches, while still handling external changes to the blocklist (e.g., from other system software) by merging them with the last known custom list (`/sdcard1/combined-blocklist.txt`). If the process isn't running, it resets monitoring. All actions are logged with timestamps for debugging.
|
||||
|
||||
The script's core functionality—automated blocklist updating, merging, validation, and DNS restarts—serves as the foundation for a modern web application. This app transforms the script into a user-friendly, secure web tool with a backend for automation and a frontend for interactive management. It maintains the script's logic for PID monitoring and auto-reapplication but adds a dark-themed web interface using Tailwind CSS for accessibility and aesthetics. The backend (preferably implemented in Go for efficiency and cross-platform compatibility) handles file operations, process management, and security, while the frontend provides intuitive controls. This setup is ideal for home lab enthusiasts, small network admins, or Unifi users seeking a Pi-hole or AdGuard Home-like experience with custom Unifi integration.
|
||||
|
||||
The app emphasizes security: all inputs are validated and sanitized to prevent code injection (e.g., via SQL injection if using a database, or command injection in shell calls). It supports a single administrative user with multi-factor authentication (MFA, e.g., TOTP via apps like Google Authenticator), configurable at runtime via command-line flags. Without authentication, no backend interactions are allowed—users are redirected to a login page. Configuration is stored in a JSON or YAML file (`config.json` or similar) in the executable's directory; if absent, the app generates a default config with placeholders for user credentials, paths, and settings.
|
||||
|
||||
#### Key Features and Architecture
|
||||
The web app replicates and extends the script's behavior, dividing responsibilities between backend and frontend while preserving modularity (e.g., via functions/services for update checks, merging, and restarts). It runs as a standalone executable (e.g., `./unifi-blocklist-manager -port=8080`), potentially as a service on a Unifi gateway or separate server.
|
||||
|
||||
##### Backend (Go-Recommended Implementation)
|
||||
- **PID Monitoring Loop**: Mirrors the script's infinite loop, checking CoreDNS PID every 5 seconds (configurable). If a change is detected without a user-initiated "Apply" action (tracked via an in-memory flag or timestamp), it triggers the blocklist update/merge logic automatically. Logs all actions to stdout or a file for auditing.
|
||||
- **Blocklist Update Logic**:
|
||||
- **Full Update**: If forced or 3+ days since last update (tracked via `/sdcard1/last_update.txt`), fetch domains from URLs in `/sdcard1/urllist.txt` (or a database equivalent for scalability). Merge with existing blocklist, clean invalid domains (e.g., using regex filters like the script's `grep` patterns), apply removals from `/run/utm/domain_list/domainlist_1.list` (via AWK-like logic in Go), and save to the active blocklist file. Update timestamp and restart CoreDNS (`pkill coredns`).
|
||||
- **Partial/Merge Update**: If time threshold not met, compare active blocklist with last custom list (`/sdcard1/combined-blocklist.txt`). If different (e.g., due to external modifications), merge uniquely (sort and dedupe), save, and restart CoreDNS.
|
||||
- Default URLs are pre-populated if the URL list file is missing, pulling from ad-blocking sources like AdGuard filters, Firebog, and Phishing Army.
|
||||
- **Process Management**: Securely execute system commands (e.g., `pgrep`, `pkill`) with input sanitization. Handle non-running CoreDNS by resetting PID file.
|
||||
- **Authentication and Config**:
|
||||
- Single user account: Configured via flags like `./app -setup-user=username -setup-pass=password -setup-mfa-secret=base32secret`. MFA uses TOTP; on first run, generate and display a QR code for scanning.
|
||||
- Config file: Auto-created if missing, containing encrypted credentials, file paths (e.g., blocklist locations), update delay (default 3 days), and check interval (default 5 seconds). Use Go libraries like Viper for config management.
|
||||
- Session-based auth: JWT or cookie sessions with short expiration; MFA required on login. Unauthenticated requests to management endpoints return 401/redirect.
|
||||
- **API Endpoints** (RESTful, e.g., using Gin framework):
|
||||
- GET `/api/urllists`: List current URLs (with status: enabled/disabled).
|
||||
- POST `/api/urllists/add`: Add new URL (validate as valid HTTP(S) URL).
|
||||
- DELETE `/api/urllists/remove`: Remove URL.
|
||||
- PATCH `/api/urllists/toggle`: Enable/disable URL.
|
||||
- GET `/api/domains/search?query=example*`: Search blocklist with wildcard support (e.g., using regex like `.*example.*` for `*example*`).
|
||||
- POST `/api/domains/add`: Add domain (validate as valid FQDN).
|
||||
- DELETE `/api/domains/remove`: Remove domain.
|
||||
- POST `/api/apply`: Trigger full/partial update and CoreDNS restart (sets a flag to indicate user-initiated change).
|
||||
- All endpoints require auth headers; validate inputs (e.g., no shell metachars, length limits).
|
||||
- **Security Measures**: Use prepared statements if database involved; escape all shell args; rate-limit API calls; HTTPS enforcement.
|
||||
|
||||
##### Frontend (Dark Theme with Tailwind CSS)
|
||||
- **Dashboard Layout**: A clean, responsive single-page app (SPA) or server-rendered pages (e.g., using Go templates or a framework like Echo with HTMX for interactivity). Dark theme by default (e.g., Tailwind's dark mode with slate/gray palettes, accents in blue for buttons).
|
||||
- **URL List Management**:
|
||||
- Table view of URLs from `/sdcard1/urllist.txt` (fetched via API), showing URL, description (auto-fetched or manual), enabled status, and actions (add, remove, toggle).
|
||||
- Add form: Input for new URL, with validation (client-side regex for URL format).
|
||||
- Similar to AdGuard Home's "Filters" tab or Pi-hole's "Adlists" under Group Management.
|
||||
- **Domain Search and Management**:
|
||||
- Search bar for final blocklist domains, supporting wildcards (e.g., `*google*` searches regex-matched entries). Results in a paginated table with domain, source (if trackable), and remove button.
|
||||
- Add domain form: Simple input for FQDN, append to blocklist or a custom additions file.
|
||||
- Inspired by Pi-hole's query log/search or AdGuard Home's domain filters.
|
||||
- **Apply Changes**: Prominent button that calls `/api/apply`, showing progress spinner and confirmation (e.g., "Changes applied, CoreDNS restarted"). Warns about potential network disruption.
|
||||
- **Login Page**: Form for username/password + MFA code. Responsive, with error messages for invalid creds.
|
||||
- **Additional Views**:
|
||||
- Logs page: Real-time or polled view of script-like logs (e.g., PID changes, updates).
|
||||
- Settings: View/edit config (e.g., update delay), but sensitive fields read-only.
|
||||
- **UI Best Practices**: Mobile-friendly (Tailwind responsive classes), accessible (ARIA labels), and minimalistic. No JavaScript-heavy if possible; use forms and HTMX for AJAX-like updates to keep it lightweight.
|
||||
|
||||
#### Usage and Deployment
|
||||
- Run: `./unifi-blocklist-manager [-port=8080] [-force] [-setup-user=...]`. The `-force` flag enables full updates on every trigger, overriding time checks.
|
||||
- Deployment: As a systemd service on Linux (e.g., Unifi OS). Expose on local network; use reverse proxy (e.g., Nginx) for HTTPS.
|
||||
- Similar Tools: Draws from Pi-hole (adlist management, group blocking) and AdGuard Home (filter lists, domain search) for UI inspiration, but tailored for Unifi/CoreDNS integration.
|
||||
|
||||
### Extra notes:
|
||||
- create Dark theme Tailwind CSS front end, what works good on desktop pc as on small mobile device screen
|
||||
- use html template, so the layout is preserved across all pages ( similar what Python Jinja templates do - i define base.html and then i referense it in index.html and extend it with page content...)
|
||||
- The user input should be secure and interaction with website and if someone would attempt to use some non-interactive access via bot or terminal it should not work for them, forms should use CSRF tokens - and only authenticated user should have access
|
||||
- as this will be small, you do not need database, file config for configuration should be enough ( the config would have hashed password and username, what can be set with command line flags used with the executable, for example `--new-user admin --password Admin123!`
|
||||
- to change password `--change-password admin <some strong new password>`
|
||||
- MFA would be possible to setup when user signs in to website, in their profile
|
||||
- the config file if does not exists will be created on first run in same folder as is executable
|
||||
112
auth.go
Normal file
112
auth.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
store = sessions.NewCookieStore([]byte("super-secret-key")) // Change this in production
|
||||
appStartupTime time.Time
|
||||
)
|
||||
|
||||
func initAuth() {
|
||||
appStartupTime = time.Now()
|
||||
}
|
||||
|
||||
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := store.Get(r, "session")
|
||||
if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate session version - invalidate if app restarted or password changed
|
||||
if sessionVersion, ok := session.Values["version"].(string); !ok || sessionVersion != getSessionVersion() {
|
||||
session.Values["authenticated"] = false
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Check session startup time - invalidate if app was restarted
|
||||
if sessionStartupTime, ok := session.Values["startup_time"].(int64); !ok || sessionStartupTime != appStartupTime.Unix() {
|
||||
session.Values["authenticated"] = false
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Check session expiration
|
||||
if sessionCreatedAt, ok := session.Values["created_at"].(int64); ok {
|
||||
configMu.RLock()
|
||||
timeoutMins := config.SessionTimeoutMins
|
||||
configMu.RUnlock()
|
||||
|
||||
if time.Now().Unix()-sessionCreatedAt > int64(timeoutMins*60) {
|
||||
session.Values["authenticated"] = false
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
mfaCode := r.FormValue("mfa_code")
|
||||
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
|
||||
if username != config.Username || bcrypt.CompareHashAndPassword([]byte(config.HashedPass), []byte(password)) != nil {
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if config.MFASecret != "" {
|
||||
valid := totp.Validate(mfaCode, config.MFASecret)
|
||||
if !valid {
|
||||
http.Error(w, "Invalid MFA code", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
session, _ := store.Get(r, "session")
|
||||
session.Values["authenticated"] = true
|
||||
session.Values["version"] = getSessionVersion()
|
||||
session.Values["startup_time"] = appStartupTime.Unix()
|
||||
session.Values["created_at"] = time.Now().Unix()
|
||||
session.Save(r, w)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
renderTemplate(w, r, "login.html", nil)
|
||||
}
|
||||
|
||||
func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := store.Get(r, "session")
|
||||
session.Values["authenticated"] = false
|
||||
session.Values["version"] = ""
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func getSessionVersion() string {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
// Create a version string from password hash - changes when password changes
|
||||
return config.HashedPass
|
||||
}
|
||||
205
blocklist.go
Normal file
205
blocklist.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func updateBlocklist(force int) {
|
||||
configMu.RLock()
|
||||
delayDays := config.UpdateDelay
|
||||
configMu.RUnlock()
|
||||
|
||||
if checkIfUpdateNeeded(delayDays, force) {
|
||||
createDefaultURLList()
|
||||
fetchAndMergeSources()
|
||||
applyRemovalRules()
|
||||
finalizeBlocklist()
|
||||
} else {
|
||||
handleSkippedUpdate(delayDays)
|
||||
}
|
||||
}
|
||||
|
||||
func checkIfUpdateNeeded(delayDays, force int) bool {
|
||||
configMu.RLock()
|
||||
lastUpdateFile := config.LastUpdateFile
|
||||
configMu.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(lastUpdateFile)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
lastTS, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
nowTS := time.Now().Unix()
|
||||
|
||||
delaySeconds := int64(delayDays * 86400)
|
||||
|
||||
if force == 0 && nowTS-lastTS < delaySeconds {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func handleSkippedUpdate(delayDays int) {
|
||||
configMu.RLock()
|
||||
blocklistFile := config.BlocklistFile
|
||||
tmpFile := config.TmpFile
|
||||
configMu.RUnlock()
|
||||
|
||||
log.Printf("%s: Full Update skipped — last update was less than %d days ago. Use force to override.", time.Now(), delayDays)
|
||||
|
||||
blockData, _ := os.ReadFile(blocklistFile)
|
||||
tmpData, _ := os.ReadFile(tmpFile)
|
||||
|
||||
if string(blockData) == string(tmpData) {
|
||||
log.Printf("%s: Custom Blocklist still in use, doing nothing.", time.Now())
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Second)
|
||||
log.Printf("%s: Copying existing blocklist to %s", time.Now(), blocklistFile)
|
||||
|
||||
merged := mergeUnique(string(blockData), string(tmpData))
|
||||
os.WriteFile(blocklistFile, []byte(merged), 0644)
|
||||
|
||||
log.Printf("%s: Restarting CoreDNS to apply new blocklist file.", time.Now())
|
||||
exec.Command("pkill", "coredns").Run()
|
||||
}
|
||||
|
||||
func createDefaultURLList() {
|
||||
configMu.RLock()
|
||||
urlFileList := config.URLFileList
|
||||
defaultURLs := config.DefaultURLs
|
||||
configMu.RUnlock()
|
||||
|
||||
_, err := os.Stat(urlFileList)
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("%s: Using Default url list: %s", time.Now(), urlFileList)
|
||||
writeLines(urlFileList, defaultURLs)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAndMergeSources() {
|
||||
configMu.RLock()
|
||||
tmpFile := config.TmpFile
|
||||
blocklistFile := config.BlocklistFile
|
||||
urlFileList := config.URLFileList
|
||||
configMu.RUnlock()
|
||||
|
||||
os.Create(tmpFile)
|
||||
|
||||
blockData, _ := os.ReadFile(blocklistFile)
|
||||
var builder strings.Builder
|
||||
builder.Write(blockData)
|
||||
|
||||
urls, _ := readLines(urlFileList)
|
||||
for _, url := range urls {
|
||||
if strings.HasPrefix(url, "#") {
|
||||
continue
|
||||
}
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
lines := strings.Split(string(body), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "!") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "||")
|
||||
line = strings.TrimSuffix(line, "^")
|
||||
builder.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
lines := strings.Split(builder.String(), "\n")
|
||||
sort.Strings(lines)
|
||||
unique := []string{}
|
||||
seen := make(map[string]bool)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || seen[line] {
|
||||
continue
|
||||
}
|
||||
if !isValidDomain(line) {
|
||||
continue
|
||||
}
|
||||
unique = append(unique, line)
|
||||
seen[line] = true
|
||||
}
|
||||
|
||||
os.WriteFile(tmpFile, []byte(strings.Join(unique, "\n")), 0644)
|
||||
log.Printf("%s: Combined List cleanup.", time.Now())
|
||||
}
|
||||
|
||||
func applyRemovalRules() {
|
||||
configMu.RLock()
|
||||
removeFile := config.RemoveFile
|
||||
tmpFile := config.TmpFile
|
||||
configMu.RUnlock()
|
||||
|
||||
removeLines, _ := readLines(removeFile)
|
||||
var patterns []*regexp.Regexp
|
||||
for _, rule := range removeLines {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" {
|
||||
continue
|
||||
}
|
||||
rule = regexp.QuoteMeta(rule)
|
||||
rule = strings.ReplaceAll(rule, "\\*", ".*")
|
||||
re, _ := regexp.Compile("^" + rule + "$")
|
||||
patterns = append(patterns, re)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(tmpFile)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
var filtered []string
|
||||
for _, line := range lines {
|
||||
drop := false
|
||||
for _, re := range patterns {
|
||||
if re.MatchString(line) {
|
||||
drop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !drop {
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
}
|
||||
|
||||
os.WriteFile(tmpFile+".filtered", []byte(strings.Join(filtered, "\n")), 0644)
|
||||
}
|
||||
|
||||
func finalizeBlocklist() {
|
||||
configMu.RLock()
|
||||
tmpFile := config.TmpFile
|
||||
blocklistFile := config.BlocklistFile
|
||||
lastUpdateFile := config.LastUpdateFile
|
||||
configMu.RUnlock()
|
||||
|
||||
os.Rename(tmpFile+".filtered", tmpFile)
|
||||
copyFile(tmpFile, blocklistFile)
|
||||
|
||||
now := time.Now().Unix()
|
||||
os.WriteFile(lastUpdateFile, []byte(strconv.FormatInt(now, 10)), 0644)
|
||||
|
||||
log.Printf("%s: Restarting CoreDNS to apply new blocklist file", time.Now())
|
||||
exec.Command("pkill", "coredns").Run()
|
||||
|
||||
stat, _ := os.Stat(blocklistFile)
|
||||
size := stat.Size()
|
||||
lines, _ := readLines(blocklistFile)
|
||||
log.Printf("%s: Blocklist created at: %s with size %d and %d lines", time.Now(), blocklistFile, size, len(lines))
|
||||
}
|
||||
196
config.go
Normal file
196
config.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Application
|
||||
AppName string `json:"app_name"`
|
||||
|
||||
// Authentication
|
||||
Username string `json:"username"`
|
||||
HashedPass string `json:"hashed_password"`
|
||||
MFASecret string `json:"mfa_secret"`
|
||||
SessionTimeoutMins int `json:"session_timeout_minutes"`
|
||||
|
||||
// Server
|
||||
Port string `json:"port"`
|
||||
BindAddr string `json:"bind_address"`
|
||||
|
||||
// Blocklist settings
|
||||
UpdateDelay int `json:"update_delay_days"`
|
||||
CheckInterval int `json:"check_interval_seconds"`
|
||||
|
||||
// File paths
|
||||
PidFile string `json:"pid_file"`
|
||||
ProcessName string `json:"process_name"`
|
||||
TmpFile string `json:"tmp_file"`
|
||||
LastUpdateFile string `json:"last_update_file"`
|
||||
URLFileList string `json:"url_file_list"`
|
||||
BlocklistFile string `json:"blocklist_file"`
|
||||
RemoveFile string `json:"remove_file"`
|
||||
MergedListTmp string `json:"merged_list_tmp"`
|
||||
|
||||
// Default blocklist URLs
|
||||
DefaultURLs []string `json:"default_urls"`
|
||||
}
|
||||
|
||||
var (
|
||||
config Config
|
||||
configFile = "config.json"
|
||||
configMu sync.RWMutex
|
||||
)
|
||||
|
||||
func getDefaultConfig() Config {
|
||||
return Config{
|
||||
AppName: "Unifi Blocklist Manager",
|
||||
Username: "admin",
|
||||
HashedPass: "", // Will be set with hashed password
|
||||
MFASecret: "",
|
||||
SessionTimeoutMins: 60, // 1 hour default
|
||||
Port: "8080",
|
||||
BindAddr: "0.0.0.0",
|
||||
UpdateDelay: 3,
|
||||
CheckInterval: 5,
|
||||
PidFile: "/tmp/coredns_last.pid",
|
||||
ProcessName: "coredns",
|
||||
TmpFile: "/sdcard1/combined-blocklist.txt",
|
||||
LastUpdateFile: "/sdcard1/last_update.txt",
|
||||
URLFileList: "/sdcard1/urllist.txt",
|
||||
BlocklistFile: "/run/utm/domain_list/domainlist_0.list",
|
||||
RemoveFile: "/run/utm/domain_list/domainlist_1.list",
|
||||
MergedListTmp: "/tmp/mergedlist.txt",
|
||||
DefaultURLs: []string{
|
||||
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt",
|
||||
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_49.txt",
|
||||
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt",
|
||||
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt",
|
||||
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt",
|
||||
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_23.txt",
|
||||
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt",
|
||||
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt",
|
||||
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt",
|
||||
"https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt",
|
||||
"https://osint.digitalside.it/Threat-Intel/lists/latestdomains.txt",
|
||||
"https://v.firebog.net/hosts/Prigent-Crypto.txt",
|
||||
"https://phishing.army/download/phishing_army_blocklist_extended.txt",
|
||||
"https://v.firebog.net/hosts/static/w3kbl.txt",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
file, err := os.Open(configFile)
|
||||
if err != nil {
|
||||
log.Println("Config not found, creating default")
|
||||
// Create default config with admin user
|
||||
defaultConfig := getDefaultConfig()
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte("Password123!"), bcrypt.DefaultCost)
|
||||
defaultConfig.HashedPass = string(hashed)
|
||||
config = defaultConfig
|
||||
saveConfigLocked()
|
||||
log.Println("Default config created with admin/Password123!")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
err = json.NewDecoder(file).Decode(&config)
|
||||
if err != nil {
|
||||
log.Printf("Error reading config: %v, using defaults", err)
|
||||
defaultConfig := getDefaultConfig()
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte("Password123!"), bcrypt.DefaultCost)
|
||||
defaultConfig.HashedPass = string(hashed)
|
||||
config = defaultConfig
|
||||
}
|
||||
}
|
||||
|
||||
func saveConfigLocked() {
|
||||
file, err := os.Create(configFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
err = encoder.Encode(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveConfig() {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
saveConfigLocked()
|
||||
}
|
||||
|
||||
func handleMFACommand(cmd string) {
|
||||
switch cmd {
|
||||
case "on":
|
||||
configMu.Lock()
|
||||
if config.MFASecret == "" {
|
||||
// Generate new secret
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "UnifiBlocklist",
|
||||
AccountName: config.Username,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating MFA: %v\n", err)
|
||||
configMu.Unlock()
|
||||
os.Exit(1)
|
||||
}
|
||||
config.MFASecret = key.Secret()
|
||||
saveConfigLocked()
|
||||
fmt.Printf("MFA enabled.\nSecret: %s\nOTP URL: %s\n", key.Secret(), key.URL())
|
||||
} else {
|
||||
fmt.Println("MFA is already enabled")
|
||||
configMu.Unlock()
|
||||
return
|
||||
}
|
||||
configMu.Unlock()
|
||||
|
||||
case "off":
|
||||
configMu.Lock()
|
||||
if config.MFASecret != "" {
|
||||
config.MFASecret = ""
|
||||
saveConfigLocked()
|
||||
fmt.Println("MFA disabled")
|
||||
} else {
|
||||
fmt.Println("MFA is not enabled")
|
||||
}
|
||||
configMu.Unlock()
|
||||
|
||||
case "reset":
|
||||
configMu.Lock()
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "UnifiBlocklist",
|
||||
AccountName: config.Username,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating MFA: %v\n", err)
|
||||
configMu.Unlock()
|
||||
os.Exit(1)
|
||||
}
|
||||
config.MFASecret = key.Secret()
|
||||
saveConfigLocked()
|
||||
fmt.Printf("MFA reset.\nNew Secret: %s\nOTP URL: %s\nScan with your authenticator app.\n", key.Secret(), key.URL())
|
||||
configMu.Unlock()
|
||||
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Invalid MFA command. Use: on, off, or reset\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
176
handlers.go
Normal file
176
handlers.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func profileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
configMu.RLock()
|
||||
mfaEnabled := config.MFASecret != ""
|
||||
configMu.RUnlock()
|
||||
|
||||
data := struct {
|
||||
MFAEnabled bool
|
||||
}{
|
||||
MFAEnabled: mfaEnabled,
|
||||
}
|
||||
renderTemplate(w, r, "profile.html", data)
|
||||
}
|
||||
|
||||
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
renderTemplate(w, r, "dashboard.html", nil)
|
||||
}
|
||||
|
||||
func urlListsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
action := r.FormValue("action")
|
||||
url := sanitizeInput(r.FormValue("url"))
|
||||
indexStr := r.FormValue("index")
|
||||
|
||||
configMu.RLock()
|
||||
urlFileList := config.URLFileList
|
||||
configMu.RUnlock()
|
||||
|
||||
urls, err := readLines(urlFileList)
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading URL list", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
if isValidURL(url) {
|
||||
urls = append(urls, url)
|
||||
}
|
||||
case "remove":
|
||||
index, _ := strconv.Atoi(indexStr)
|
||||
if index >= 0 && index < len(urls) {
|
||||
urls = append(urls[:index], urls[index+1:]...)
|
||||
}
|
||||
case "toggle":
|
||||
// Assume enabled/disabled by commenting out with #
|
||||
index, _ := strconv.Atoi(indexStr)
|
||||
if index >= 0 && index < len(urls) {
|
||||
if strings.HasPrefix(urls[index], "#") {
|
||||
urls[index] = strings.TrimPrefix(urls[index], "#")
|
||||
} else {
|
||||
urls[index] = "#" + urls[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeLines(urlFileList, urls)
|
||||
http.Redirect(w, r, "/urllists", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
configMu.RLock()
|
||||
urlFileList := config.URLFileList
|
||||
defaultURLs := config.DefaultURLs
|
||||
configMu.RUnlock()
|
||||
|
||||
urls, _ := readLines(urlFileList)
|
||||
if len(urls) == 0 {
|
||||
writeLines(urlFileList, defaultURLs)
|
||||
urls = defaultURLs
|
||||
}
|
||||
|
||||
data := struct {
|
||||
URLs []string
|
||||
}{
|
||||
URLs: urls,
|
||||
}
|
||||
renderTemplate(w, r, "urllists.html", data)
|
||||
}
|
||||
|
||||
func domainsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
action := r.FormValue("action")
|
||||
domain := sanitizeInput(r.FormValue("domain"))
|
||||
|
||||
configMu.RLock()
|
||||
blocklistFile := config.BlocklistFile
|
||||
configMu.RUnlock()
|
||||
|
||||
domains, err := readLines(blocklistFile)
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading domains", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
if isValidDomain(domain) {
|
||||
domains = append(domains, domain)
|
||||
sort.Strings(domains)
|
||||
}
|
||||
case "remove":
|
||||
for i, d := range domains {
|
||||
if d == domain {
|
||||
domains = append(domains[:i], domains[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeLines(blocklistFile, domains)
|
||||
http.Redirect(w, r, "/domains", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
configMu.RLock()
|
||||
blocklistFile := config.BlocklistFile
|
||||
configMu.RUnlock()
|
||||
|
||||
query := r.URL.Query().Get("query")
|
||||
domains, _ := readLines(blocklistFile)
|
||||
|
||||
var filtered []string
|
||||
if query != "" {
|
||||
query = strings.ReplaceAll(query, "*", ".*")
|
||||
re, err := regexp.Compile("(?i)" + query)
|
||||
if err == nil {
|
||||
for _, d := range domains {
|
||||
if re.MatchString(d) {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filtered = domains
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Domains []string
|
||||
Query string
|
||||
}{
|
||||
Domains: filtered,
|
||||
Query: query,
|
||||
}
|
||||
renderTemplate(w, r, "domains.html", data)
|
||||
}
|
||||
|
||||
func applyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userInitiatedMu.Lock()
|
||||
userInitiated = true
|
||||
userInitiatedMu.Unlock()
|
||||
|
||||
updateBlocklist(1) // Force
|
||||
|
||||
userInitiatedMu.Lock()
|
||||
userInitiated = false
|
||||
userInitiatedMu.Unlock()
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func logsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// For simplicity, assume logs are in stdout, or implement file logging
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Logs placeholder"))
|
||||
}
|
||||
4
info.txt
Normal file
4
info.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
go build -o unifi-blocklist-app main.go 2>&1 && echo "✓ Build successful"
|
||||
|
||||
|
||||
go build -o unifi-blocklist-app main.go
|
||||
79
main.go
Normal file
79
main.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/justinas/nosurf"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pw := flag.String("pw", "", "Generate hashed password (e.g., -pw 'MyPassword123!'). Outputs hash to terminal without starting app.")
|
||||
mfaCmd := flag.String("mfa", "", "Manage MFA: 'on' (enforce), 'off' (disable), 'reset' (remove and show secret). Does not start app.")
|
||||
port := flag.String("port", "", "Port to listen on (overrides config). Default: 8080")
|
||||
bindAddr := flag.String("bind", "", "Bind address (overrides config). Default: 0.0.0.0")
|
||||
flag.Parse()
|
||||
|
||||
// Handle password hashing flag
|
||||
if *pw != "" {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(*pw), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error hashing password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Hashed password (copy to config file):\n%s\n", string(hashed))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
loadConfig()
|
||||
|
||||
// Handle MFA management flag
|
||||
if *mfaCmd != "" {
|
||||
handleMFACommand(*mfaCmd)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Initialize authentication (set startup time)
|
||||
initAuth()
|
||||
|
||||
// Override config with command-line flags if provided
|
||||
if *port != "" {
|
||||
config.Port = *port
|
||||
}
|
||||
if *bindAddr != "" {
|
||||
config.BindAddr = *bindAddr
|
||||
}
|
||||
|
||||
go monitorPID()
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Static files
|
||||
staticFS, _ := fs.Sub(content, "static")
|
||||
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// Public routes
|
||||
r.HandleFunc("/login", loginHandler).Methods("GET", "POST")
|
||||
r.HandleFunc("/logout", logoutHandler)
|
||||
|
||||
// Protected routes
|
||||
r.HandleFunc("/profile", authMiddleware(profileHandler)).Methods("GET", "POST")
|
||||
r.HandleFunc("/", authMiddleware(dashboardHandler))
|
||||
r.HandleFunc("/urllists", authMiddleware(urlListsHandler)).Methods("GET", "POST")
|
||||
r.HandleFunc("/domains", authMiddleware(domainsHandler)).Methods("GET", "POST")
|
||||
r.HandleFunc("/apply", authMiddleware(applyHandler)).Methods("POST")
|
||||
r.HandleFunc("/logs", authMiddleware(logsHandler))
|
||||
|
||||
// CSRF protection middleware
|
||||
csrfHandler := nosurf.New(r)
|
||||
|
||||
listenAddr := config.BindAddr + ":" + config.Port
|
||||
log.Printf("Starting server on %s", listenAddr)
|
||||
http.ListenAndServe(listenAddr, csrfHandler)
|
||||
}
|
||||
86
monitor.go
Normal file
86
monitor.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
userInitiated bool
|
||||
userInitiatedMu sync.Mutex
|
||||
)
|
||||
|
||||
func monitorPID() {
|
||||
for {
|
||||
lastPID := readPID()
|
||||
currentPID := getPID()
|
||||
|
||||
if currentPID != "" {
|
||||
if currentPID != lastPID {
|
||||
log.Printf("%s: PID changed: %s -> %s", time.Now(), lastPID, currentPID)
|
||||
log.Printf("%s: Running Blocklist update", time.Now())
|
||||
|
||||
userInitiatedMu.Lock()
|
||||
force := 0
|
||||
if userInitiated {
|
||||
force = 1
|
||||
}
|
||||
userInitiatedMu.Unlock()
|
||||
|
||||
updateBlocklist(force)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
currentPID = getPID()
|
||||
writePID(currentPID)
|
||||
}
|
||||
} else {
|
||||
configMu.RLock()
|
||||
processName := config.ProcessName
|
||||
configMu.RUnlock()
|
||||
log.Printf("%s: %s not running!", time.Now(), processName)
|
||||
writePID("")
|
||||
}
|
||||
|
||||
configMu.RLock()
|
||||
checkInterval := config.CheckInterval
|
||||
configMu.RUnlock()
|
||||
time.Sleep(time.Duration(checkInterval) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func readPID() string {
|
||||
configMu.RLock()
|
||||
pidFile := config.PidFile
|
||||
configMu.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func writePID(pid string) {
|
||||
configMu.RLock()
|
||||
pidFile := config.PidFile
|
||||
configMu.RUnlock()
|
||||
|
||||
os.WriteFile(pidFile, []byte(pid), 0644)
|
||||
}
|
||||
|
||||
func getPID() string {
|
||||
configMu.RLock()
|
||||
processName := config.ProcessName
|
||||
configMu.RUnlock()
|
||||
|
||||
cmd := exec.Command("pgrep", "-f", processName)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
1
static/info.txt
Normal file
1
static/info.txt
Normal file
@@ -0,0 +1 @@
|
||||
tailwindcss -o static/tailwind.css --minify
|
||||
2
static/tailwind.css
Normal file
2
static/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
31
templates/base.html
Normal file
31
templates/base.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ with .PageData }}{{ .Title }}{{ end }} - {{ .AppName }}</title>
|
||||
<link href="/static/tailwind.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
|
||||
<header class="bg-gray-800 p-4">
|
||||
<nav class="flex justify-between">
|
||||
<a href="/" class="text-xl font-bold">{{ .AppName }}</a>
|
||||
{{ if .Authenticated }}
|
||||
<ul class="flex space-x-4">
|
||||
<li><a href="/urllists">URL Lists</a></li>
|
||||
<li><a href="/domains">Domains</a></li>
|
||||
<li><a href="/logs">Logs</a></li>
|
||||
<li><a href="/profile">Profile</a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
{{ end }}
|
||||
</nav>
|
||||
</header>
|
||||
<main class="flex-grow container mx-auto p-4">
|
||||
{{ block "content" . }}{{ end }}
|
||||
</main>
|
||||
<footer class="bg-gray-800 p-4 text-center">
|
||||
© 2025 {{ .AppName }}
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
8
templates/dashboard.html
Normal file
8
templates/dashboard.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{{ define "content" }}
|
||||
<h1 class="text-2xl mb-4">Dashboard</h1>
|
||||
<p>Welcome to Unifi Custom Blocklist Manager.</p>
|
||||
<form method="POST" action="/apply">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<button type="submit" class="bg-green-600 p-2 rounded">Apply Changes</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
28
templates/domains.html
Normal file
28
templates/domains.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{{ define "content" }}
|
||||
<h1 class="text-2xl mb-4">Domains</h1>
|
||||
<form method="GET" class="mb-4">
|
||||
<input type="text" name="query" placeholder="Search (use * for wildcard)" value="{{ with .PageData }}{{ .Query }}{{ end }}" class="p-2 bg-gray-700 rounded">
|
||||
<button type="submit" class="bg-blue-600 p-2 rounded">Search</button>
|
||||
</form>
|
||||
<form method="POST" class="mb-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<input type="text" name="domain" placeholder="Add Domain" class="p-2 bg-gray-700 rounded">
|
||||
<button type="submit" class="bg-green-600 p-2 rounded">Add</button>
|
||||
</form>
|
||||
<ul class="space-y-2">
|
||||
{{ with .PageData }}
|
||||
{{ range .Domains }}
|
||||
<li class="flex justify-between bg-gray-800 p-2 rounded">
|
||||
{{ . }}
|
||||
<form method="POST" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="action" value="remove">
|
||||
<input type="hidden" name="domain" value="{{ . }}">
|
||||
<button type="submit" class="bg-red-600 p-1 rounded">Remove</button>
|
||||
</form>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
9
templates/login.html
Normal file
9
templates/login.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{{ define "content" }}
|
||||
<form method="POST" class="max-w-md mx-auto bg-gray-800 p-6 rounded">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<input type="text" name="username" placeholder="Username" class="w-full mb-4 p-2 bg-gray-700 rounded" required>
|
||||
<input type="password" name="password" placeholder="Password" class="w-full mb-4 p-2 bg-gray-700 rounded" required>
|
||||
<input type="text" name="mfa_code" placeholder="MFA Code (if enabled)" class="w-full mb-4 p-2 bg-gray-700 rounded">
|
||||
<button type="submit" class="w-full bg-blue-600 p-2 rounded">Login</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
6
templates/mfa_setup.html
Normal file
6
templates/mfa_setup.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{{ define "content" }}
|
||||
<h1 class="text-2xl mb-4">MFA Setup</h1>
|
||||
<p>MFA Secret: {{ with .PageData }}{{ .MFASecret }}{{ end }}</p>
|
||||
<p>OTP URL: {{ with .PageData }}{{ .OTPURL }}{{ end }}</p>
|
||||
<p>Use a QR code generator with the OTP URL or enter the secret in your authenticator app.</p>
|
||||
{{ end }}
|
||||
17
templates/profile.html
Normal file
17
templates/profile.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{{ define "content" }}
|
||||
<h1 class="text-2xl mb-4">Profile</h1>
|
||||
{{ with .PageData }}
|
||||
<div class="bg-gray-800 p-4 rounded mb-4">
|
||||
<p class="mb-2"><strong>Username:</strong> admin</p>
|
||||
<p class="mb-2">
|
||||
<strong>MFA Status:</strong>
|
||||
{{ if .MFAEnabled }}
|
||||
<span class="text-green-400">Enabled</span>
|
||||
{{ else }}
|
||||
<span class="text-yellow-400">Disabled</span>
|
||||
{{ end }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-400 mt-4">To manage MFA or change password, use the command-line interface with the executable flags.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
32
templates/urllists.html
Normal file
32
templates/urllists.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{ define "content" }}
|
||||
<h1 class="text-2xl mb-4">URL Lists</h1>
|
||||
<form method="POST" class="mb-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<input type="text" name="url" placeholder="Add URL" class="p-2 bg-gray-700 rounded">
|
||||
<button type="submit" class="bg-blue-600 p-2 rounded">Add</button>
|
||||
</form>
|
||||
<ul class="space-y-2">
|
||||
{{ with .PageData }}
|
||||
{{ range $i, $url := .URLs }}
|
||||
<li class="flex justify-between bg-gray-800 p-2 rounded">
|
||||
{{ $url }}
|
||||
<div>
|
||||
<form method="POST" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="action" value="toggle">
|
||||
<input type="hidden" name="index" value="{{ $i }}">
|
||||
<button type="submit" class="bg-yellow-600 p-1 rounded">Toggle</button>
|
||||
</form>
|
||||
<form method="POST" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="action" value="remove">
|
||||
<input type="hidden" name="index" value="{{ $i }}">
|
||||
<button type="submit" class="bg-red-600 p-1 rounded">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
0
test/coredns_last.pid
Normal file
0
test/coredns_last.pid
Normal file
237
test/unifi_custom_blocklist.sh
Normal file
237
test/unifi_custom_blocklist.sh
Normal file
@@ -0,0 +1,237 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration variables
|
||||
PROCESS_NAME="coredns"
|
||||
PID_FILE="/tmp/coredns_last.pid"
|
||||
CHECK_INTERVAL=5
|
||||
UPDATE_DELAY_DAYS=3
|
||||
TMP_FILE="/sdcard1/combined-blocklist.txt"
|
||||
LAST_UPDATE_FILE="/sdcard1/last_update.txt"
|
||||
URL_FILE_LIST="/sdcard1/urllist.txt"
|
||||
BLOCKLIST_FILE="/run/utm/domain_list/domainlist_0.list"
|
||||
REMOVE_FILE="/run/utm/domain_list/domainlist_1.list"
|
||||
MERGED_LIST_TMP="/tmp/mergedlist.txt"
|
||||
|
||||
# Default URL list content
|
||||
DEFAULT_URLS=$(cat << 'EOF'
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_49.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_23.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt
|
||||
https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt
|
||||
https://osint.digitalside.it/Threat-Intel/lists/latestdomains.txt
|
||||
https://v.firebog.net/hosts/Prigent-Crypto.txt
|
||||
https://phishing.army/download/phishing_army_blocklist_extended.txt
|
||||
https://v.firebog.net/hosts/static/w3kbl.txt
|
||||
EOF
|
||||
)
|
||||
|
||||
# Parse command-line arguments for force
|
||||
FORCE=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-f|--force)
|
||||
FORCE=1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Function to check if full update is needed
|
||||
check_if_update_needed() {
|
||||
local lastupdatefile="$1"
|
||||
local update_delay_days="$2"
|
||||
local force="$3"
|
||||
|
||||
if [[ -f "$lastupdatefile" ]]; then
|
||||
last_update_ts=$(cat "$lastupdatefile")
|
||||
now_ts=$(date +%s)
|
||||
|
||||
# How many seconds must pass
|
||||
delay_seconds=$((update_delay_days * 86400))
|
||||
|
||||
# If not forced and enough time has not passed → return 1 (skip full update)
|
||||
if [[ $force -eq 0 ]] && (( now_ts - last_update_ts < delay_seconds )); then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to handle skipped update actions
|
||||
handle_skipped_update() {
|
||||
local blocklistfile="$1"
|
||||
local tmpfile="$2"
|
||||
local update_delay_days="$3"
|
||||
|
||||
echo "$(date): Full Update skipped — last update was less than $update_delay_days days ago. Use -f or --force to override."
|
||||
if cmp -s "$blocklistfile" "$tmpfile" ; then
|
||||
echo "$(date): Custom Blocklist still in use, doing nothing."
|
||||
else
|
||||
sleep 10
|
||||
echo "$(date): Copying existing blocklist to $blocklistfile"
|
||||
sort "$blocklistfile" "$tmpfile" | uniq > "$MERGED_LIST_TMP"
|
||||
mv "$MERGED_LIST_TMP" "$blocklistfile"
|
||||
echo "$(date): Restarting CoreDNS to apply new blocklist file."
|
||||
pkill coredns
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to create default URL list if not exists
|
||||
create_default_url_list() {
|
||||
local url_file_list="$1"
|
||||
local default_urls="$2"
|
||||
|
||||
if [ ! -f "$url_file_list" ]; then
|
||||
echo "$(date): Using Default url list: $url_file_list"
|
||||
echo "$default_urls" > "$url_file_list"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to fetch and merge sources
|
||||
fetch_and_merge_sources() {
|
||||
local blocklistfile="$1"
|
||||
local url_file_list="$2"
|
||||
local tmpfile="$3"
|
||||
|
||||
touch "$tmpfile"
|
||||
{
|
||||
# existing file
|
||||
cat "$blocklistfile"
|
||||
|
||||
# URL sources from file
|
||||
while IFS= read -r url; do
|
||||
# Trim leading/trailing whitespace
|
||||
url="$(echo "$url" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
||||
|
||||
# Skip empty or commented lines
|
||||
[ -z "$url" ] && continue
|
||||
case "$url" in
|
||||
\#*|\!* ) continue ;;
|
||||
esac
|
||||
# Fetch & clean
|
||||
curl -s "$url" \
|
||||
| grep -v '^[!#]' \
|
||||
| sed '/^\s*$/d' \
|
||||
| sed 's/^||//' \
|
||||
| sed 's/\^$//'
|
||||
|
||||
done < "$url_file_list"
|
||||
} | sort -u \
|
||||
| grep -Ev '^\.' \
|
||||
| grep -Ev '^\*-' \
|
||||
| grep -Ev '^-' \
|
||||
| grep -Ev '^/' \
|
||||
| grep -Ev '\*' \
|
||||
| grep -E '^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$' \
|
||||
> "$tmpfile"
|
||||
|
||||
echo "$(date): Combined List cleanup."
|
||||
}
|
||||
|
||||
# Function to apply removal rules using AWK
|
||||
apply_removal_rules() {
|
||||
local tmpfile="$1"
|
||||
local removefile="$2"
|
||||
|
||||
awk -v patfile="$removefile" '
|
||||
BEGIN {
|
||||
# load removal rules
|
||||
while ((getline rule < patfile) > 0) {
|
||||
# skip empty lines / pure whitespace
|
||||
if (rule ~ /^[ \t]*$/) continue
|
||||
|
||||
# Escape regex special chars except *
|
||||
gsub(/[][(){}+?.\\^$|]/, "\\\\&", rule)
|
||||
|
||||
# Convert * into .*
|
||||
gsub(/\*/, ".*", rule)
|
||||
|
||||
patterns[++n] = "^" rule "$"
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
drop = 0
|
||||
# Check against each pattern
|
||||
for (i = 1; i <= n; i++) {
|
||||
if ($0 ~ patterns[i]) {
|
||||
drop = 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!drop) print
|
||||
}
|
||||
' "$tmpfile" > "${tmpfile}.filtered"
|
||||
}
|
||||
|
||||
# Function to finalize the blocklist
|
||||
finalize_blocklist() {
|
||||
local tmpfile="$1"
|
||||
local blocklistfile="$2"
|
||||
local lastupdatefile="$3"
|
||||
|
||||
mv "${tmpfile}.filtered" "$tmpfile"
|
||||
cp "$tmpfile" "$blocklistfile"
|
||||
|
||||
# Save update timestamp.
|
||||
date +%s > "$lastupdatefile"
|
||||
|
||||
echo "$(date): Restarting CoreDNS to apply new blocklist file"
|
||||
pkill coredns
|
||||
echo "$(date): Blocklist created at: $blocklistfile with size $(du -sh $blocklistfile) and $(wc -l < $blocklistfile) lines"
|
||||
}
|
||||
|
||||
# Main function to update the blocklist
|
||||
update_blocklist() {
|
||||
local force="$1"
|
||||
|
||||
if check_if_update_needed "$LAST_UPDATE_FILE" "$UPDATE_DELAY_DAYS" "$force"; then
|
||||
create_default_url_list "$URL_FILE_LIST" "$DEFAULT_URLS"
|
||||
fetch_and_merge_sources "$BLOCKLIST_FILE" "$URL_FILE_LIST" "$TMP_FILE"
|
||||
apply_removal_rules "$TMP_FILE" "$REMOVE_FILE"
|
||||
finalize_blocklist "$TMP_FILE" "$BLOCKLIST_FILE" "$LAST_UPDATE_FILE"
|
||||
else
|
||||
handle_skipped_update "$BLOCKLIST_FILE" "$TMP_FILE" "$UPDATE_DELAY_DAYS"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "$(date): Starting PID check..."
|
||||
|
||||
while true; do
|
||||
# Load last PID from file
|
||||
LAST_PID=""
|
||||
[[ -f "$PID_FILE" ]] && LAST_PID=$(cat "$PID_FILE")
|
||||
|
||||
# Get current PID
|
||||
CURRENT_PID=$(pgrep -f "$PROCESS_NAME")
|
||||
|
||||
if [[ -n "$CURRENT_PID" ]]; then
|
||||
# Process is running
|
||||
if [[ "$CURRENT_PID" != "$LAST_PID" ]]; then
|
||||
echo "$(date): PID changed: $LAST_PID -> $CURRENT_PID"
|
||||
echo "$(date): Running Blocklist update"
|
||||
|
||||
# Call the update function with the global FORCE
|
||||
update_blocklist "$FORCE"
|
||||
|
||||
# Update PID file
|
||||
sleep 2
|
||||
CURRENT_PID=$(pgrep -f "$PROCESS_NAME")
|
||||
echo "$CURRENT_PID" > "$PID_FILE"
|
||||
fi
|
||||
else
|
||||
# Process not running
|
||||
echo "$(date): $PROCESS_NAME not running!"
|
||||
|
||||
# Reset PID file
|
||||
echo "" > "$PID_FILE"
|
||||
fi
|
||||
|
||||
sleep $CHECK_INTERVAL
|
||||
done
|
||||
14
test/urllist.txt
Normal file
14
test/urllist.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_49.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_23.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt
|
||||
https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt
|
||||
https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt
|
||||
https://osint.digitalside.it/Threat-Intel/lists/latestdomains.txt
|
||||
https://v.firebog.net/hosts/Prigent-Crypto.txt
|
||||
https://phishing.army/download/phishing_army_blocklist_extended.txt
|
||||
https://v.firebog.net/hosts/static/w3kbl.txt
|
||||
BIN
unifi-blocklist-app
Executable file
BIN
unifi-blocklist-app
Executable file
Binary file not shown.
147
utils.go
Normal file
147
utils.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var content embed.FS
|
||||
|
||||
type TemplateData struct {
|
||||
CSRFToken string
|
||||
PageData interface{}
|
||||
Authenticated bool
|
||||
AppName string
|
||||
}
|
||||
|
||||
func renderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, pageData interface{}) {
|
||||
// Check if user is authenticated
|
||||
session, _ := store.Get(r, "session")
|
||||
isAuth := false
|
||||
if auth, ok := session.Values["authenticated"].(bool); ok {
|
||||
isAuth = auth
|
||||
}
|
||||
|
||||
configMu.RLock()
|
||||
appName := config.AppName
|
||||
configMu.RUnlock()
|
||||
|
||||
td := TemplateData{
|
||||
CSRFToken: nosurf.Token(r),
|
||||
PageData: pageData,
|
||||
Authenticated: isAuth,
|
||||
AppName: appName,
|
||||
}
|
||||
|
||||
templatesFS, _ := fs.Sub(content, "templates")
|
||||
// Parse base.html and the specific page template together
|
||||
files := []string{"base.html", tmpl}
|
||||
t, err := template.ParseFS(templatesFS, files...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Execute base.html which contains {{ block "content" . }}
|
||||
// The specific page template's {{ define "content" }} will override the block
|
||||
err = t.ExecuteTemplate(w, "base.html", td)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
source, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
destination, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destination.Close()
|
||||
|
||||
_, err = io.Copy(destination, source)
|
||||
return err
|
||||
}
|
||||
|
||||
func readLines(file string) ([]string, error) {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
var cleaned []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
cleaned = append(cleaned, line)
|
||||
}
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func writeLines(file string, lines []string) {
|
||||
data := strings.Join(lines, "\n")
|
||||
os.WriteFile(file, []byte(data), 0644)
|
||||
}
|
||||
|
||||
func mergeUnique(data1, data2 string) string {
|
||||
lines1 := strings.Split(data1, "\n")
|
||||
lines2 := strings.Split(data2, "\n")
|
||||
all := append(lines1, lines2...)
|
||||
|
||||
// Sort for consistency
|
||||
var sorted []string
|
||||
seen := make(map[string]bool)
|
||||
for _, line := range all {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || seen[line] {
|
||||
continue
|
||||
}
|
||||
sorted = append(sorted, line)
|
||||
seen[line] = true
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
for i := 0; i < len(sorted); i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
if sorted[i] > sorted[j] {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(sorted, "\n")
|
||||
}
|
||||
|
||||
func sanitizeInput(input string) string {
|
||||
// Basic sanitization, remove dangerous chars
|
||||
re := regexp.MustCompile(`[;<>&|]`)
|
||||
return re.ReplaceAllString(input, "")
|
||||
}
|
||||
|
||||
func isValidURL(u string) bool {
|
||||
return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")
|
||||
}
|
||||
|
||||
func isValidDomain(d string) bool {
|
||||
re := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$`)
|
||||
return re.MatchString(d)
|
||||
}
|
||||
|
||||
func generateSecret() string {
|
||||
// This function is kept for backward compatibility but is not currently used
|
||||
// since we're using pquerna/otp for TOTP generation
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user