first commit

This commit is contained in:
2025-12-06 07:47:30 +00:00
commit 17a0bf05cc
23 changed files with 1458 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
go.mod
go.sum
config.json*

65
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
tailwindcss -o static/tailwind.css --minify

2
static/tailwind.css Normal file

File diff suppressed because one or more lines are too long

31
templates/base.html Normal file
View 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">
&copy; 2025 {{ .AppName }}
</footer>
</body>
</html>

8
templates/dashboard.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

View 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
View 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

Binary file not shown.

147
utils.go Normal file
View 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 ""
}