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")) }