batman
This commit is contained in:
commit
682f25edcd
19 changed files with 1907 additions and 0 deletions
499
main.go
Normal file
499
main.go
Normal file
|
@ -0,0 +1,499 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ddns-manager/db"
|
||||
"ddns-manager/templates"
|
||||
|
||||
"github.com/cloudflare/cloudflare-go"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
type DNSRecord struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
TTL int `json:"ttl"`
|
||||
Proxied bool `json:"proxied"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
}
|
||||
|
||||
type UpdateFrequency struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
var (
|
||||
api *cloudflare.API
|
||||
scheduler *cron.Cron
|
||||
lastIP string
|
||||
jobID cron.EntryID
|
||||
queries *db.Queries
|
||||
)
|
||||
|
||||
func initDatabase() (*sql.DB, error) {
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./ddns.db"
|
||||
}
|
||||
|
||||
log.Printf("Using database path: %s", dbPath)
|
||||
|
||||
sqlDB, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.InitSchema(sqlDB); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
||||
}
|
||||
|
||||
queries = db.New(sqlDB)
|
||||
|
||||
return sqlDB, nil
|
||||
}
|
||||
|
||||
func initCloudflare(apiToken, zoneID string) error {
|
||||
if apiToken == "" || zoneID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
api, err = cloudflare.NewWithAPIToken(apiToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize Cloudflare API: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCurrentIP() (string, error) {
|
||||
resp, err := http.Get("https://api.ipify.org")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get current IP: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
ip, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read IP response: %w", err)
|
||||
}
|
||||
|
||||
return string(ip), nil
|
||||
}
|
||||
|
||||
func getDNSRecords(zoneID string) ([]DNSRecord, error) {
|
||||
if api == nil {
|
||||
return nil, fmt.Errorf("cloudflare API not initialized")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rc := cloudflare.ZoneIdentifier(zoneID)
|
||||
|
||||
recs, _, err := api.ListDNSRecords(ctx, rc, cloudflare.ListDNSRecordsParams{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get DNS records: %w", err)
|
||||
}
|
||||
|
||||
var records []DNSRecord
|
||||
for _, rec := range recs {
|
||||
records = append(records, DNSRecord{
|
||||
ID: rec.ID,
|
||||
Type: rec.Type,
|
||||
Name: rec.Name,
|
||||
Content: rec.Content,
|
||||
TTL: rec.TTL,
|
||||
Proxied: *rec.Proxied,
|
||||
CreatedOn: rec.CreatedOn.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func createDNSRecord(zoneID, domain, name, recordType, content string, ttl int, proxied bool) error {
|
||||
if api == nil {
|
||||
return fmt.Errorf("cloudflare API not initialized")
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(name, domain) && name != "@" {
|
||||
name = name + "." + domain
|
||||
}
|
||||
|
||||
if name == "@" {
|
||||
name = domain
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rc := cloudflare.ZoneIdentifier(zoneID)
|
||||
|
||||
_, err := api.CreateDNSRecord(ctx, rc, cloudflare.CreateDNSRecordParams{
|
||||
Type: recordType,
|
||||
Name: name,
|
||||
Content: content,
|
||||
TTL: ttl,
|
||||
Proxied: &proxied,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create DNS record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateDNSRecord(zoneID, id, name, recordType, content string, ttl int, proxied bool) error {
|
||||
if api == nil {
|
||||
return fmt.Errorf("cloudflare API not initialized")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rc := cloudflare.ZoneIdentifier(zoneID)
|
||||
|
||||
_, err := api.UpdateDNSRecord(ctx, rc, cloudflare.UpdateDNSRecordParams{
|
||||
ID: id,
|
||||
Type: recordType,
|
||||
Name: name,
|
||||
Content: content,
|
||||
TTL: ttl,
|
||||
Proxied: &proxied,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update DNS record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteDNSRecord(zoneID, id string) error {
|
||||
if api == nil {
|
||||
return fmt.Errorf("cloudflare API not initialized")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rc := cloudflare.ZoneIdentifier(zoneID)
|
||||
|
||||
err := api.DeleteDNSRecord(ctx, rc, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete DNS record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAllRecordsWithCurrentIP(zoneID string) error {
|
||||
if api == nil {
|
||||
return fmt.Errorf("cloudflare API not initialized")
|
||||
}
|
||||
|
||||
currentIP, err := getCurrentIP()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentIP == lastIP {
|
||||
log.Println("IP hasn't changed, no updates needed")
|
||||
return nil
|
||||
}
|
||||
|
||||
lastIP = currentIP
|
||||
|
||||
ctx := context.Background()
|
||||
rc := cloudflare.ZoneIdentifier(zoneID)
|
||||
|
||||
records, _, err := api.ListDNSRecords(ctx, rc, cloudflare.ListDNSRecordsParams{
|
||||
Type: "A",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get DNS records: %w", err)
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
if rec.Content != currentIP {
|
||||
proxied := rec.Proxied
|
||||
_, err := api.UpdateDNSRecord(ctx, rc, cloudflare.UpdateDNSRecordParams{
|
||||
ID: rec.ID,
|
||||
Type: rec.Type,
|
||||
Name: rec.Name,
|
||||
Content: currentIP,
|
||||
TTL: rec.TTL,
|
||||
Proxied: proxied,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to update record %s: %v", rec.Name, err)
|
||||
} else {
|
||||
log.Printf("Updated record %s to %s", rec.Name, currentIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scheduleUpdates(zoneID, updatePeriod string) error {
|
||||
if jobID != 0 {
|
||||
scheduler.Remove(jobID)
|
||||
}
|
||||
|
||||
if updatePeriod == "" {
|
||||
log.Println("Automatic updates disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
jobID, err = scheduler.AddFunc(updatePeriod, func() {
|
||||
log.Println("Running scheduled IP update")
|
||||
if err := updateAllRecordsWithCurrentIP(zoneID); err != nil {
|
||||
log.Printf("Scheduled update failed: %v", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to schedule updates: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Scheduled IP updates with cron: %s", updatePeriod)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
sqlDB, err := initDatabase()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
config, err := queries.GetConfig(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load configuration: %v", err)
|
||||
config = db.Config{
|
||||
Domain: "mz.uy",
|
||||
UpdatePeriod: "0 */6 * * *",
|
||||
}
|
||||
}
|
||||
|
||||
if err := initCloudflare(config.ApiToken, config.ZoneID); err != nil {
|
||||
log.Printf("Warning: Cloudflare initialization failed: %v", err)
|
||||
}
|
||||
|
||||
scheduler = cron.New()
|
||||
scheduler.Start()
|
||||
defer scheduler.Stop()
|
||||
|
||||
if config.ApiToken != "" && config.ZoneID != "" && config.UpdatePeriod != "" {
|
||||
if err := scheduleUpdates(config.ZoneID, config.UpdatePeriod); err != nil {
|
||||
log.Printf("Warning: Failed to schedule updates: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
e.Static("/assets", "assets")
|
||||
|
||||
apiGroup := e.Group("/api")
|
||||
{
|
||||
apiGroup.GET("/config", func(c echo.Context) error {
|
||||
configStatus := struct {
|
||||
IsConfigured bool `json:"is_configured"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
Domain string `json:"domain"`
|
||||
UpdatePeriod string `json:"update_period"`
|
||||
}{
|
||||
IsConfigured: config.ApiToken != "" && config.ZoneID != "",
|
||||
ZoneID: config.ZoneID,
|
||||
Domain: config.Domain,
|
||||
UpdatePeriod: config.UpdatePeriod,
|
||||
}
|
||||
return c.JSON(http.StatusOK, configStatus)
|
||||
})
|
||||
|
||||
apiGroup.POST("/config", func(c echo.Context) error {
|
||||
var newConfig struct {
|
||||
APIToken string `json:"api_token"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
Domain string `json:"domain"`
|
||||
UpdatePeriod string `json:"update_period"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&newConfig); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request"})
|
||||
}
|
||||
|
||||
err := queries.UpsertConfig(context.Background(), db.UpsertConfigParams{
|
||||
ApiToken: newConfig.APIToken,
|
||||
ZoneID: newConfig.ZoneID,
|
||||
Domain: newConfig.Domain,
|
||||
UpdatePeriod: newConfig.UpdatePeriod,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to save configuration: %v", err)})
|
||||
}
|
||||
|
||||
config.ApiToken = newConfig.APIToken
|
||||
config.ZoneID = newConfig.ZoneID
|
||||
config.Domain = newConfig.Domain
|
||||
config.UpdatePeriod = newConfig.UpdatePeriod
|
||||
|
||||
if err := initCloudflare(config.ApiToken, config.ZoneID); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to initialize Cloudflare client: %v", err)})
|
||||
}
|
||||
|
||||
if err := scheduleUpdates(config.ZoneID, config.UpdatePeriod); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to schedule updates: %v", err)})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Configuration updated successfully"})
|
||||
})
|
||||
|
||||
apiGroup.GET("/update-frequencies", func(c echo.Context) error {
|
||||
frequencies := []UpdateFrequency{
|
||||
{Label: "Every 1 minutes", Value: "*/1 * * * *"},
|
||||
{Label: "Every 5 minutes", Value: "*/5 * * * *"},
|
||||
{Label: "Every 30 minutes", Value: "*/30 * * * *"},
|
||||
{Label: "Hourly", Value: "0 * * * *"},
|
||||
{Label: "Every 6 hours", Value: "0 */6 * * *"},
|
||||
{Label: "Daily", Value: "0 0 * * *"},
|
||||
{Label: "Never (manual only)", Value: ""},
|
||||
}
|
||||
return c.JSON(http.StatusOK, frequencies)
|
||||
})
|
||||
|
||||
apiGroup.GET("/current-ip", func(c echo.Context) error {
|
||||
ip, err := getCurrentIP()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to get current IP: %v", err)})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"ip": ip})
|
||||
})
|
||||
|
||||
apiGroup.GET("/records", func(c echo.Context) error {
|
||||
if config.ApiToken == "" || config.ZoneID == "" {
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{Error: "API not configured"})
|
||||
}
|
||||
|
||||
records, err := getDNSRecords(config.ZoneID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to get DNS records: %v", err)})
|
||||
}
|
||||
return c.JSON(http.StatusOK, records)
|
||||
})
|
||||
|
||||
apiGroup.POST("/records", func(c echo.Context) error {
|
||||
if config.ApiToken == "" || config.ZoneID == "" {
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{Error: "API not configured"})
|
||||
}
|
||||
|
||||
var record struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
TTL int `json:"ttl"`
|
||||
Proxied bool `json:"proxied"`
|
||||
UseMyIP bool `json:"use_my_ip"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&record); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request"})
|
||||
}
|
||||
|
||||
if record.UseMyIP {
|
||||
ip, err := getCurrentIP()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to get current IP: %v", err)})
|
||||
}
|
||||
record.Content = ip
|
||||
}
|
||||
|
||||
if err := createDNSRecord(config.ZoneID, config.Domain, record.Name, record.Type, record.Content, record.TTL, record.Proxied); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to create DNS record: %v", err)})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "DNS record created successfully"})
|
||||
})
|
||||
|
||||
apiGroup.PUT("/records/:id", func(c echo.Context) error {
|
||||
if config.ApiToken == "" || config.ZoneID == "" {
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{Error: "API not configured"})
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
var record struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
TTL int `json:"ttl"`
|
||||
Proxied bool `json:"proxied"`
|
||||
UseMyIP bool `json:"use_my_ip"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&record); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request"})
|
||||
}
|
||||
|
||||
if record.UseMyIP {
|
||||
ip, err := getCurrentIP()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to get current IP: %v", err)})
|
||||
}
|
||||
record.Content = ip
|
||||
}
|
||||
|
||||
if err := updateDNSRecord(config.ZoneID, id, record.Name, record.Type, record.Content, record.TTL, record.Proxied); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to update DNS record: %v", err)})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "DNS record updated successfully"})
|
||||
})
|
||||
|
||||
apiGroup.DELETE("/records/:id", func(c echo.Context) error {
|
||||
if config.ApiToken == "" || config.ZoneID == "" {
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{Error: "API not configured"})
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if err := deleteDNSRecord(config.ZoneID, id); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to delete DNS record: %v", err)})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "DNS record deleted successfully"})
|
||||
})
|
||||
|
||||
apiGroup.POST("/update-all", func(c echo.Context) error {
|
||||
if config.ApiToken == "" || config.ZoneID == "" {
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{Error: "API not configured"})
|
||||
}
|
||||
|
||||
if err := updateAllRecordsWithCurrentIP(config.ZoneID); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to update records: %v", err)})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "All A records updated with current IP"})
|
||||
})
|
||||
}
|
||||
|
||||
e.GET("/", func(c echo.Context) error {
|
||||
component := templates.Index(templates.IndexProps{
|
||||
Title: "mz.uy DNS Manager",
|
||||
})
|
||||
return templates.Render(c.Response(), component)
|
||||
})
|
||||
|
||||
log.Println("Starting server on http://localhost:3000")
|
||||
log.Fatal(e.Start(":3000"))
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue