From 682f25edcde623ba2937103e411e878e8096fae6 Mon Sep 17 00:00:00 2001 From: "Mariano Z." Date: Sat, 3 May 2025 17:36:17 -0300 Subject: [PATCH] batman --- .gitignore | 1 + Dockerfile | 27 ++ assets/js/app.js | 512 ++++++++++++++++++++++++++++++++++++++ db/db.go | 31 +++ db/models.go | 12 + db/queries.sql | 32 +++ db/queries.sql.go | 68 +++++ db/schema.sql | 6 + db/util.go | 15 ++ go.mod | 26 ++ go.sum | 49 ++++ main.go | 499 +++++++++++++++++++++++++++++++++++++ sqlc.yaml | 13 + templates/index.templ | 136 ++++++++++ templates/index_templ.go | 93 +++++++ templates/layout.templ | 56 +++++ templates/layout_templ.go | 70 ++++++ templates/modal.templ | 190 ++++++++++++++ templates/modal_templ.go | 71 ++++++ 19 files changed, 1907 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 assets/js/app.js create mode 100644 db/db.go create mode 100644 db/models.go create mode 100644 db/queries.sql create mode 100644 db/queries.sql.go create mode 100644 db/schema.sql create mode 100644 db/util.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 sqlc.yaml create mode 100644 templates/index.templ create mode 100644 templates/index_templ.go create mode 100644 templates/layout.templ create mode 100644 templates/layout_templ.go create mode 100644 templates/modal.templ create mode 100644 templates/modal_templ.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0a4282 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +./mzdns.db* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5da0e9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +############################ +# STEP 1: Build executable +############################ +FROM golang:1.24.1-alpine AS builder +RUN apk add --no-cache git build-base sqlite-dev +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go install github.com/a-h/templ/cmd/templ@latest +RUN templ generate +RUN CGO_ENABLED=1 go build -o ddns-manager . + +############################ +# STEP 2: Build final image +############################ +FROM alpine:latest + +RUN apk add --no-cache sqlite-libs ca-certificates tzdata +WORKDIR /app +COPY --from=builder /app/ddns-manager /app/ +COPY --from=builder /app/assets /app/assets +VOLUME /data +EXPOSE 3000 +ENV DB_PATH=/data/ddns.db + +ENTRYPOINT ["/app/ddns-manager"] diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..f45a2cd --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,512 @@ +// Global variables +let isConfigured = false; +let currentIP = ""; +let domainName = "mz.uy"; + +// Helper functions +function showToast(message, type = "success") { + const toastContainer = document.querySelector(".toast-container"); + const toast = document.createElement("div"); + toast.className = `toast align-items-center text-white bg-${type}`; + toast.setAttribute("role", "alert"); + toast.setAttribute("aria-live", "assertive"); + toast.setAttribute("aria-atomic", "true"); + + toast.innerHTML = ` +
+
+ ${message} +
+ +
+ `; + + toastContainer.appendChild(toast); + const bsToast = new bootstrap.Toast(toast); + bsToast.show(); + + // Remove the toast after it's hidden + toast.addEventListener("hidden.bs.toast", () => { + toast.remove(); + }); +} + +// Initialize the application +async function initApp() { + // Show loading indicator + document.getElementById("loading").style.display = "block"; + document.getElementById("config-warning").style.display = "none"; + document.getElementById("config-status").style.display = "none"; + document.getElementById("dns-records-section").style.display = "none"; + + // Load configuration + await loadConfig(); + + // Load current IP + await loadCurrentIP(); + + // Load update frequencies for the dropdown + await loadUpdateFrequencies(); + + // If configured, load DNS records + if (isConfigured) { + await loadDNSRecords(); + } + + // Hide loading indicator + document.getElementById("loading").style.display = "none"; +} + +// Load configuration +async function loadConfig() { + try { + const response = await fetch("/api/config"); + const data = await response.json(); + + isConfigured = data.is_configured; + domainName = data.domain; + + if (isConfigured) { + document.getElementById("config-warning").style.display = "none"; + document.getElementById("config-status").style.display = "block"; + document.getElementById("dns-records-section").style.display = "block"; + + document.getElementById("zone-id").textContent = data.zone_id; + document.getElementById("domain-name").textContent = data.domain; + document.getElementById("domain-suffix").textContent = "." + data.domain; + document.getElementById("domain-input").value = data.domain; + document.getElementById("zone-id-input").value = data.zone_id; + + // Set the update schedule display + let scheduleDisplay = "Manual updates only"; + if (data.update_period) { + switch (data.update_period) { + case "*/5 * * * *": + scheduleDisplay = "Every 5 minutes"; + break; + case "*/30 * * * *": + scheduleDisplay = "Every 30 minutes"; + break; + case "0 * * * *": + scheduleDisplay = "Hourly"; + break; + case "0 */6 * * *": + scheduleDisplay = "Every 6 hours"; + break; + case "0 0 * * *": + scheduleDisplay = "Daily"; + break; + default: + scheduleDisplay = data.update_period; + } + } + document.getElementById("update-schedule").textContent = scheduleDisplay; + } else { + document.getElementById("config-warning").style.display = "block"; + document.getElementById("config-status").style.display = "none"; + document.getElementById("dns-records-section").style.display = "none"; + } + } catch (error) { + console.error("Failed to load configuration:", error); + showToast("Failed to load configuration: " + error.message, "danger"); + } +} + +// Load current IP +async function loadCurrentIP() { + try { + const response = await fetch("/api/current-ip"); + const data = await response.json(); + + currentIP = data.ip; + document.getElementById("current-ip").textContent = currentIP; + } catch (error) { + console.error("Failed to load current IP:", error); + document.getElementById("current-ip").textContent = "Failed to load"; + } +} + +// Load update frequencies +async function loadUpdateFrequencies() { + try { + const response = await fetch("/api/update-frequencies"); + const frequencies = await response.json(); + + const select = document.getElementById("update-period"); + select.innerHTML = ""; + + frequencies.forEach((freq) => { + const option = document.createElement("option"); + option.value = freq.value; + option.textContent = freq.label; + select.appendChild(option); + }); + } catch (error) { + console.error("Failed to load update frequencies:", error); + } +} + +// Load DNS records +async function loadDNSRecords() { + try { + const response = await fetch("/api/records"); + if (!response.ok) { + throw new Error("Failed to fetch DNS records"); + } + + const records = await response.json(); + const tbody = document.getElementById("dns-records"); + tbody.innerHTML = ""; + + if (records.length === 0) { + const tr = document.createElement("tr"); + tr.innerHTML = + 'No DNS records found'; + tbody.appendChild(tr); + return; + } + + records.forEach((record) => { + const tr = document.createElement("tr"); + + // Highlight records that match the current IP + const isCurrentIP = record.type === "A" && record.content === currentIP; + const ipBadge = isCurrentIP + ? 'Current IP' + : record.type === "A" + ? 'Outdated IP' + : ""; + + tr.innerHTML = ` + ${record.type} + ${record.name} + ${record.content} ${ipBadge} + ${record.ttl === 1 ? "Auto" : record.ttl + "s"} + ${record.proxied ? '' : ""} + + + + + `; + + tbody.appendChild(tr); + }); + + // Add event listeners for edit and delete buttons + document.querySelectorAll(".edit-record").forEach((button) => { + button.addEventListener("click", () => editRecord(button.dataset.id)); + }); + + document.querySelectorAll(".delete-record").forEach((button) => { + button.addEventListener("click", () => + deleteRecord(button.dataset.id, button.dataset.name), + ); + }); + } catch (error) { + console.error("Failed to load DNS records:", error); + showToast("Failed to load DNS records: " + error.message, "danger"); + } +} + +// Edit a DNS record +async function editRecord(id) { + try { + const response = await fetch("/api/records"); + const records = await response.json(); + + const record = records.find((r) => r.id === id); + if (!record) { + showToast("Record not found", "danger"); + return; + } + + // Open the modal + const modal = new bootstrap.Modal(document.getElementById("recordModal")); + modal.show(); + + // Update modal title + document.getElementById("recordModalLabel").textContent = "Edit DNS Record"; + + // Fill the form + document.getElementById("record-id").value = record.id; + + // Set the subdomain name without the domain suffix + let name = record.name; + if (name === domainName) { + name = "@"; + } else if (name.endsWith("." + domainName)) { + name = name.substring(0, name.length - domainName.length - 1); + } + document.getElementById("record-name").value = name; + + document.getElementById("record-type").value = record.type; + document.getElementById("record-content").value = record.content; + document.getElementById("record-ttl").value = record.ttl; + document.getElementById("record-proxied").checked = record.proxied; + document.getElementById("use-my-ip").checked = false; + + // Show/hide the "Use my current IP" option based on record type + toggleMyIPOption(); + } catch (error) { + console.error("Failed to load record:", error); + showToast("Failed to load record: " + error.message, "danger"); + } +} + +// Delete a DNS record +async function deleteRecord(id, name) { + if (!confirm(`Are you sure you want to delete the record for "${name}"?`)) { + return; + } + + try { + const response = await fetch(`/api/records/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to delete record"); + } + + showToast(`Record for "${name}" deleted successfully`); + await loadDNSRecords(); + } catch (error) { + console.error("Failed to delete record:", error); + showToast("Failed to delete record: " + error.message, "danger"); + } +} + +// Toggle the "Use my current IP" option based on record type +function toggleMyIPOption() { + const recordType = document.getElementById("record-type").value; + const useMyIPGroup = document.getElementById("use-my-ip-group"); + const contentGroup = document.getElementById("content-group"); + + if (recordType === "A") { + useMyIPGroup.style.display = "block"; + + // If the checkbox is checked, hide the content field + const useMyIP = document.getElementById("use-my-ip").checked; + contentGroup.style.display = useMyIP ? "none" : "block"; + } else { + useMyIPGroup.style.display = "none"; + contentGroup.style.display = "block"; + } +} + +// Event listeners +document.addEventListener("DOMContentLoaded", function () { + // Initialize the application + initApp(); + + // Refresh IP button + document + .getElementById("refresh-ip") + .addEventListener("click", async function () { + await loadCurrentIP(); + showToast("Current IP refreshed"); + }); + + // Save configuration button + document + .getElementById("save-config") + .addEventListener("click", async function () { + const apiToken = document.getElementById("api-token").value; + const zoneId = document.getElementById("zone-id-input").value; + const domain = document.getElementById("domain-input").value; + const updatePeriod = document.getElementById("update-period").value; + + if (!apiToken || !zoneId || !domain) { + showToast("Please fill all required fields", "danger"); + return; + } + + try { + const response = await fetch("/api/config", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + api_token: apiToken, + zone_id: zoneId, + domain: domain, + update_period: updatePeriod, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save configuration"); + } + + // Hide the modal + const modal = bootstrap.Modal.getInstance( + document.getElementById("configModal"), + ); + modal.hide(); + + showToast("Configuration saved successfully"); + + // Reload the app + initApp(); + } catch (error) { + console.error("Failed to save configuration:", error); + showToast("Failed to save configuration: " + error.message, "danger"); + } + }); + + // Record type change event + document + .getElementById("record-type") + .addEventListener("change", toggleMyIPOption); + + // Use my IP checkbox change event + document.getElementById("use-my-ip").addEventListener("change", function () { + const contentGroup = document.getElementById("content-group"); + contentGroup.style.display = this.checked ? "none" : "block"; + + if (this.checked) { + document.getElementById("record-content").value = currentIP; + } + }); + + // Save record button + document + .getElementById("save-record") + .addEventListener("click", async function () { + const id = document.getElementById("record-id").value; + let name = document.getElementById("record-name").value; + const type = document.getElementById("record-type").value; + const content = document.getElementById("record-content").value; + const ttl = parseInt(document.getElementById("record-ttl").value); + const proxied = document.getElementById("record-proxied").checked; + const useMyIP = document.getElementById("use-my-ip").checked; + + // Validate the form + if (!name) { + showToast("Name is required", "danger"); + return; + } + + if (!useMyIP && !content) { + showToast("Content is required", "danger"); + return; + } + + // Prepare the record data + const recordData = { + name: name, + type: type, + content: useMyIP ? "" : content, + ttl: ttl, + proxied: proxied, + use_my_ip: useMyIP, + }; + + try { + let response; + + if (id) { + // Update existing record + response = await fetch(`/api/records/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(recordData), + }); + } else { + // Create new record + response = await fetch("/api/records", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(recordData), + }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save record"); + } + + // Hide the modal + const modal = bootstrap.Modal.getInstance( + document.getElementById("recordModal"), + ); + modal.hide(); + + showToast( + id ? "Record updated successfully" : "Record created successfully", + ); + + // Reset the form + document.getElementById("record-form").reset(); + document.getElementById("record-id").value = ""; + + // Reload DNS records + await loadDNSRecords(); + } catch (error) { + console.error("Failed to save record:", error); + showToast("Failed to save record: " + error.message, "danger"); + } + }); + + // Update all records button + document + .getElementById("update-all-records") + .addEventListener("click", async function () { + if ( + !confirm( + "Are you sure you want to update all A records to your current IP?", + ) + ) { + return; + } + + try { + const response = await fetch("/api/update-all", { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to update records"); + } + + showToast("All A records updated to current IP"); + + // Reload DNS records + await loadDNSRecords(); + } catch (error) { + console.error("Failed to update records:", error); + showToast("Failed to update records: " + error.message, "danger"); + } + }); + + // Reset record form when opening the modal + document + .getElementById("recordModal") + .addEventListener("show.bs.modal", function (event) { + const button = event.relatedTarget; + + // If opening from the "Add Record" button, reset the form + if (button && button.textContent.trim().includes("Add Record")) { + document.getElementById("recordModalLabel").textContent = + "Add DNS Record"; + document.getElementById("record-form").reset(); + document.getElementById("record-id").value = ""; + document.getElementById("record-type").value = "A"; + + // Show/hide the "Use my current IP" option based on record type + toggleMyIPOption(); + } + }); +}); diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..0c56c2b --- /dev/null +++ b/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/db/models.go b/db/models.go new file mode 100644 index 0000000..5ff15b4 --- /dev/null +++ b/db/models.go @@ -0,0 +1,12 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package db + +type Config struct { + ApiToken string `json:"api_token"` + ZoneID string `json:"zone_id"` + Domain string `json:"domain"` + UpdatePeriod string `json:"update_period"` +} diff --git a/db/queries.sql b/db/queries.sql new file mode 100644 index 0000000..ba18ddc --- /dev/null +++ b/db/queries.sql @@ -0,0 +1,32 @@ +-- name: GetConfig :one +SELECT * FROM config LIMIT 1; + +-- name: UpsertConfig :exec +INSERT INTO config (api_token, zone_id, domain, update_period) +VALUES (?, ?, ?, ?) +ON CONFLICT DO UPDATE SET + api_token = excluded.api_token, + zone_id = excluded.zone_id, + domain = excluded.domain, + update_period = excluded.update_period; + +-- name: InitSchema :exec +-- This query is used to ensure the schema is set up properly +CREATE TABLE IF NOT EXISTS config ( + api_token TEXT, + zone_id TEXT, + domain TEXT NOT NULL DEFAULT 'mz.uy', + update_period TEXT NOT NULL DEFAULT '0 */6 * * *' +); + +CREATE TRIGGER IF NOT EXISTS enforce_single_config +BEFORE INSERT ON config +WHEN (SELECT COUNT(*) FROM config) > 0 +BEGIN + SELECT RAISE(FAIL, 'Only one config record allowed'); +END; + +-- Insert default config if none exists +INSERT OR IGNORE INTO config (domain, update_period) +SELECT 'mz.uy', '0 */6 * * *' +WHERE NOT EXISTS (SELECT 1 FROM config); diff --git a/db/queries.sql.go b/db/queries.sql.go new file mode 100644 index 0000000..1ab708e --- /dev/null +++ b/db/queries.sql.go @@ -0,0 +1,68 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: queries.sql + +package db + +import ( + "context" +) + +const getConfig = `-- name: GetConfig :one +SELECT api_token, zone_id, domain, update_period FROM config LIMIT 1 +` + +func (q *Queries) GetConfig(ctx context.Context) (Config, error) { + row := q.db.QueryRowContext(ctx, getConfig) + var i Config + err := row.Scan( + &i.ApiToken, + &i.ZoneID, + &i.Domain, + &i.UpdatePeriod, + ) + return i, err +} + +const initSchema = `-- name: InitSchema :exec +CREATE TABLE IF NOT EXISTS config ( + api_token TEXT, + zone_id TEXT, + domain TEXT NOT NULL DEFAULT 'mz.uy', + update_period TEXT NOT NULL DEFAULT '0 */6 * * *' +) +` + +// This query is used to ensure the schema is set up properly +func (q *Queries) InitSchema(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, initSchema) + return err +} + +const upsertConfig = `-- name: UpsertConfig :exec +INSERT INTO config (api_token, zone_id, domain, update_period) +VALUES (?, ?, ?, ?) +ON CONFLICT DO UPDATE SET + api_token = excluded.api_token, + zone_id = excluded.zone_id, + domain = excluded.domain, + update_period = excluded.update_period +` + +type UpsertConfigParams struct { + ApiToken string `json:"api_token"` + ZoneID string `json:"zone_id"` + Domain string `json:"domain"` + UpdatePeriod string `json:"update_period"` +} + +func (q *Queries) UpsertConfig(ctx context.Context, arg UpsertConfigParams) error { + _, err := q.db.ExecContext(ctx, upsertConfig, + arg.ApiToken, + arg.ZoneID, + arg.Domain, + arg.UpdatePeriod, + ) + return err +} diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..1193686 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS config ( + api_token TEXT NOT NULL DEFAULT '', + zone_id TEXT NOT NULL DEFAULT '', + domain TEXT NOT NULL DEFAULT 'mz.uy', + update_period TEXT NOT NULL DEFAULT '0 */6 * * *' +); diff --git a/db/util.go b/db/util.go new file mode 100644 index 0000000..965cf03 --- /dev/null +++ b/db/util.go @@ -0,0 +1,15 @@ +package db + +import ( + "context" + "database/sql" +) + +// InitSchema initializes the database schema +func InitSchema(db *sql.DB) error { + // Create a new Queries instance + q := New(db) + + // Execute the initialization query + return q.InitSchema(context.Background()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dee76e5 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module ddns-manager + +go 1.24.1 + +require ( + github.com/a-h/templ v0.3.865 + github.com/cloudflare/cloudflare-go v0.115.0 + github.com/labstack/echo/v4 v4.13.3 + github.com/mattn/go-sqlite3 v1.14.28 + github.com/robfig/cron/v3 v3.0.1 +) + +require ( + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/time v0.9.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dcf0502 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/a-h/templ v0.3.865 h1:nYn5EWm9EiXaDgWcMQaKiKvrydqgxDUtT1+4zU2C43A= +github.com/a-h/templ v0.3.865/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ= +github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= +github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..fbfcd26 --- /dev/null +++ b/main.go @@ -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")) +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..6f9e3ac --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "db/queries.sql" + schema: "db/schema.sql" + gen: + go: + package: "db" + out: "db" + emit_json_tags: true + emit_prepared_queries: false + emit_interface: false + emit_exact_table_names: false diff --git a/templates/index.templ b/templates/index.templ new file mode 100644 index 0000000..0ccd31e --- /dev/null +++ b/templates/index.templ @@ -0,0 +1,136 @@ +package templates + +// IndexProps contains the properties for the Index component +type IndexProps struct { + Title string +} + +// Index is the main page component +templ Index(props IndexProps) { + @Layout(props.Title) { +
+
+
+
+

{ props.Title }

+
+ Current IP: + + +
+
+ + + + + + + +
+
+ Loading... +
+

Loading...

+
+
+
+
+ @ConfigModal() + @RecordModal() + +
+ + } +} diff --git a/templates/index_templ.go b/templates/index_templ.go new file mode 100644 index 0000000..e574bc8 --- /dev/null +++ b/templates/index_templ.go @@ -0,0 +1,93 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.857 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +// IndexProps contains the properties for the Index component +type IndexProps struct { + Title string +} + +// Index is the main page component +func Index(props IndexProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(props.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 15, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Current IP:

Configuration Required

Please configure your Cloudflare API credentials to manage your DNS records.

Configuration
Domain: mz.uy
Zone ID:
IP Update Schedule:
DNS Records
TypeNameContentTTLProxiedActions
Loading...

Loading...

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ConfigModal().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = RecordModal().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout(props.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/layout.templ b/templates/layout.templ new file mode 100644 index 0000000..20ca98d --- /dev/null +++ b/templates/layout.templ @@ -0,0 +1,56 @@ +package templates + +import "io" +import "context" + +// Render renders a component to an io.Writer +func Render(w io.Writer, component templ.Component) error { + return component.Render(context.Background(), w) +} + +// Layout is the base layout for all pages +templ Layout(title string) { + + + + + + { title } + + + + + + { children... } + + + +} diff --git a/templates/layout_templ.go b/templates/layout_templ.go new file mode 100644 index 0000000..9bf56b8 --- /dev/null +++ b/templates/layout_templ.go @@ -0,0 +1,70 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.857 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "io" +import "context" + +// Render renders a component to an io.Writer +func Render(w io.Writer, component templ.Component) error { + return component.Render(context.Background(), w) +} + +// Layout is the base layout for all pages +func Layout(title string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 18, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/modal.templ b/templates/modal.templ new file mode 100644 index 0000000..a9e25d8 --- /dev/null +++ b/templates/modal.templ @@ -0,0 +1,190 @@ +package templates + +// ConfigModal is the configuration dialog component +templ ConfigModal() { + +} + +// RecordModal is the DNS record dialog component +templ RecordModal() { + +} diff --git a/templates/modal_templ.go b/templates/modal_templ.go new file mode 100644 index 0000000..86c69e6 --- /dev/null +++ b/templates/modal_templ.go @@ -0,0 +1,71 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.857 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +// ConfigModal is the configuration dialog component +func ConfigModal() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Configuration
Create a token with Zone.DNS:Edit permissions in the Cloudflare dashboard.
Found in the Cloudflare dashboard under your domain's overview page.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RecordModal is the DNS record dialog component +func RecordModal() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Add DNS Record
.mz.uy
Use @ for the root domain
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate