This commit is contained in:
Mariano Z. 2025-05-03 17:36:17 -03:00
commit 682f25edcd
Signed by: marianozunino
GPG key ID: 4C73BAD25156DACE
19 changed files with 1907 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
./mzdns.db*

27
Dockerfile Normal file
View file

@ -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"]

512
assets/js/app.js Normal file
View file

@ -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 = `
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
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 =
'<td colspan="6" class="text-center">No DNS records found</td>';
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
? '<span class="badge bg-success update-badge">Current IP</span>'
: record.type === "A"
? '<span class="badge bg-warning update-badge">Outdated IP</span>'
: "";
tr.innerHTML = `
<td>${record.type}</td>
<td>${record.name}</td>
<td>${record.content} ${ipBadge}</td>
<td>${record.ttl === 1 ? "Auto" : record.ttl + "s"}</td>
<td>${record.proxied ? '<i class="bi bi-check-lg text-success"></i>' : ""}</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1 edit-record" data-id="${record.id}">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger delete-record" data-id="${record.id}" data-name="${record.name}">
<i class="bi bi-trash"></i>
</button>
</td>
`;
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();
}
});
});

31
db/db.go Normal file
View file

@ -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,
}
}

12
db/models.go Normal file
View file

@ -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"`
}

32
db/queries.sql Normal file
View file

@ -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);

68
db/queries.sql.go Normal file
View file

@ -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
}

6
db/schema.sql Normal file
View file

@ -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 * * *'
);

15
db/util.go Normal file
View file

@ -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())
}

26
go.mod Normal file
View file

@ -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
)

49
go.sum Normal file
View file

@ -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=

499
main.go Normal file
View 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"))
}

13
sqlc.yaml Normal file
View file

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

136
templates/index.templ Normal file
View file

@ -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) {
<div class="container">
<div class="row justify-content-center">
<div class="col-md-10">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>{ props.Title }</h1>
<div class="current-ip d-flex align-items-center">
<span class="me-2">Current IP:</span>
<span id="current-ip" class="fw-bold"></span>
<button
id="refresh-ip"
class="btn btn-sm btn-outline-secondary ms-2"
title="Refresh current IP"
>
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<!-- Configuration Warning -->
<div
id="config-warning"
class="alert alert-warning config-warning"
style="display: none"
>
<h4>Configuration Required</h4>
<p>
Please configure your Cloudflare API credentials to manage your
DNS records.
</p>
<button
class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#configModal"
>
Configure Now
</button>
</div>
<!-- Configuration Status -->
<div id="config-status" class="card mb-4" style="display: none">
<div
class="card-header d-flex justify-content-between align-items-center"
>
<h5 class="mb-0">Configuration</h5>
<button
class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#configModal"
>
Edit
</button>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<strong>Domain:</strong> <span id="domain-name">mz.uy</span>
</div>
<div class="col-md-4">
<strong>Zone ID:</strong> <span id="zone-id"></span>
</div>
<div class="col-md-4">
<strong>IP Update Schedule:</strong>
<span id="update-schedule"></span>
</div>
</div>
</div>
</div>
<!-- DNS Records Section -->
<div id="dns-records-section" style="display: none">
<div class="card">
<div
class="card-header d-flex justify-content-between align-items-center"
>
<h5 class="mb-0">DNS Records</h5>
<div>
<button
id="update-all-records"
class="btn btn-sm btn-success me-2"
>
<i class="bi bi-arrow-repeat"></i> Update All to Current IP
</button>
<button
class="btn btn-sm btn-primary"
data-bs-toggle="modal"
data-bs-target="#recordModal"
>
<i class="bi bi-plus-lg"></i> Add Record
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Content</th>
<th>TTL</th>
<th>Proxied</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="dns-records">
<!-- DNS records will be inserted here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Loading Indicator -->
<div id="loading" class="text-center my-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">Loading...</p>
</div>
</div>
</div>
</div>
@ConfigModal()
@RecordModal()
<!-- Toast container for notifications -->
<div class="toast-container"></div>
<script src="/assets/js/app.js"></script>
}
}

93
templates/index_templ.go Normal file
View file

@ -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, "<div class=\"container\"><div class=\"row justify-content-center\"><div class=\"col-md-10\"><div class=\"d-flex justify-content-between align-items-center mb-4\"><h1>")
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, "</h1><div class=\"current-ip d-flex align-items-center\"><span class=\"me-2\">Current IP:</span> <span id=\"current-ip\" class=\"fw-bold\"></span> <button id=\"refresh-ip\" class=\"btn btn-sm btn-outline-secondary ms-2\" title=\"Refresh current IP\"><i class=\"bi bi-arrow-clockwise\"></i></button></div></div><!-- Configuration Warning --><div id=\"config-warning\" class=\"alert alert-warning config-warning\" style=\"display: none\"><h4>Configuration Required</h4><p>Please configure your Cloudflare API credentials to manage your DNS records.</p><button class=\"btn btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#configModal\">Configure Now</button></div><!-- Configuration Status --><div id=\"config-status\" class=\"card mb-4\" style=\"display: none\"><div class=\"card-header d-flex justify-content-between align-items-center\"><h5 class=\"mb-0\">Configuration</h5><button class=\"btn btn-sm btn-outline-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#configModal\">Edit</button></div><div class=\"card-body\"><div class=\"row\"><div class=\"col-md-4\"><strong>Domain:</strong> <span id=\"domain-name\">mz.uy</span></div><div class=\"col-md-4\"><strong>Zone ID:</strong> <span id=\"zone-id\"></span></div><div class=\"col-md-4\"><strong>IP Update Schedule:</strong> <span id=\"update-schedule\"></span></div></div></div></div><!-- DNS Records Section --><div id=\"dns-records-section\" style=\"display: none\"><div class=\"card\"><div class=\"card-header d-flex justify-content-between align-items-center\"><h5 class=\"mb-0\">DNS Records</h5><div><button id=\"update-all-records\" class=\"btn btn-sm btn-success me-2\"><i class=\"bi bi-arrow-repeat\"></i> Update All to Current IP</button> <button class=\"btn btn-sm btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#recordModal\"><i class=\"bi bi-plus-lg\"></i> Add Record</button></div></div><div class=\"card-body p-0\"><div class=\"table-responsive\"><table class=\"table table-striped table-hover mb-0\"><thead><tr><th>Type</th><th>Name</th><th>Content</th><th>TTL</th><th>Proxied</th><th>Actions</th></tr></thead> <tbody id=\"dns-records\"><!-- DNS records will be inserted here --></tbody></table></div></div></div></div><!-- Loading Indicator --><div id=\"loading\" class=\"text-center my-5\"><div class=\"spinner-border\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div><p class=\"mt-2\">Loading...</p></div></div></div></div>")
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, " <!-- Toast container for notifications --> <div class=\"toast-container\"></div><script src=\"/assets/js/app.js\"></script>")
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

56
templates/layout.templ Normal file
View file

@ -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) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"
/>
<style>
body {
padding-top: 20px;
background-color: #f8f9fa;
}
.card {
margin-bottom: 20px;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.config-warning {
margin-bottom: 20px;
}
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
}
.update-badge {
font-size: 0.8em;
margin-left: 10px;
}
</style>
</head>
<body>
{ children... }
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
}

70
templates/layout_templ.go Normal file
View file

@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
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, "</title><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css\" rel=\"stylesheet\"><link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css\"><style>\n\t\t\t\tbody {\n\t\t\t\t\tpadding-top: 20px;\n\t\t\t\t\tbackground-color: #f8f9fa;\n\t\t\t\t}\n\t\t\t\t.card {\n\t\t\t\t\tmargin-bottom: 20px;\n\t\t\t\t\tbox-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);\n\t\t\t\t}\n\t\t\t\t.config-warning {\n\t\t\t\t\tmargin-bottom: 20px;\n\t\t\t\t}\n\t\t\t\t.toast-container {\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\ttop: 20px;\n\t\t\t\t\tright: 20px;\n\t\t\t\t\tz-index: 1050;\n\t\t\t\t}\n\t\t\t\t.update-badge {\n\t\t\t\t\tfont-size: 0.8em;\n\t\t\t\t\tmargin-left: 10px;\n\t\t\t\t}\n\t\t\t</style></head><body>")
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, "<script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

190
templates/modal.templ Normal file
View file

@ -0,0 +1,190 @@
package templates
// ConfigModal is the configuration dialog component
templ ConfigModal() {
<div
class="modal fade"
id="configModal"
tabindex="-1"
aria-labelledby="configModalLabel"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="configModalLabel">Configuration</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form id="config-form">
<div class="mb-3">
<label for="api-token" class="form-label">Cloudflare API Token</label>
<input
type="password"
class="form-control"
id="api-token"
required
/>
<div class="form-text">
Create a token with <code>Zone.DNS:Edit</code> permissions in
the Cloudflare dashboard.
</div>
</div>
<div class="mb-3">
<label for="zone-id-input" class="form-label">Zone ID</label>
<input
type="text"
class="form-control"
id="zone-id-input"
required
/>
<div class="form-text">
Found in the Cloudflare dashboard under your domain's overview
page.
</div>
</div>
<div class="mb-3">
<label for="domain-input" class="form-label">Domain</label>
<input
type="text"
class="form-control"
id="domain-input"
value="mz.uy"
required
/>
</div>
<div class="mb-3">
<label for="update-period" class="form-label">Update Frequency</label>
<select class="form-select" id="update-period">
<!-- Options will be loaded from API -->
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button type="button" class="btn btn-primary" id="save-config">
Save
</button>
</div>
</div>
</div>
</div>
}
// RecordModal is the DNS record dialog component
templ RecordModal() {
<div
class="modal fade"
id="recordModal"
tabindex="-1"
aria-labelledby="recordModalLabel"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="recordModalLabel">Add DNS Record</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form id="record-form">
<input type="hidden" id="record-id"/>
<div class="mb-3">
<label for="record-name" class="form-label">Name</label>
<div class="input-group">
<input
type="text"
class="form-control"
id="record-name"
placeholder="subdomain"
required
/>
<span class="input-group-text" id="domain-suffix">.mz.uy</span>
</div>
<div class="form-text">Use @ for the root domain</div>
</div>
<div class="mb-3">
<label for="record-type" class="form-label">Type</label>
<select class="form-select" id="record-type">
<option value="A">A</option>
<option value="AAAA">AAAA</option>
<option value="CNAME">CNAME</option>
<option value="TXT">TXT</option>
<option value="MX">MX</option>
</select>
</div>
<div class="mb-3" id="content-group">
<label for="record-content" class="form-label">Content</label>
<input
type="text"
class="form-control"
id="record-content"
required
/>
</div>
<div class="mb-3 form-check" id="use-my-ip-group">
<input
type="checkbox"
class="form-check-input"
id="use-my-ip"
/>
<label class="form-check-label" for="use-my-ip">Use my current IP address</label>
</div>
<div class="mb-3">
<label for="record-ttl" class="form-label">TTL</label>
<select class="form-select" id="record-ttl">
<option value="1">Auto</option>
<option value="120">2 minutes</option>
<option value="300">5 minutes</option>
<option value="600">10 minutes</option>
<option value="1800">30 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="18000">5 hours</option>
<option value="43200">12 hours</option>
<option value="86400">1 day</option>
</select>
</div>
<div class="mb-3 form-check">
<input
type="checkbox"
class="form-check-input"
id="record-proxied"
/>
<label class="form-check-label" for="record-proxied">Proxied through Cloudflare</label>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button type="button" class="btn btn-primary" id="save-record">
Save
</button>
</div>
</div>
</div>
</div>
}

71
templates/modal_templ.go Normal file
View file

@ -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, "<div class=\"modal fade\" id=\"configModal\" tabindex=\"-1\" aria-labelledby=\"configModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"configModalLabel\">Configuration</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"config-form\"><div class=\"mb-3\"><label for=\"api-token\" class=\"form-label\">Cloudflare API Token</label> <input type=\"password\" class=\"form-control\" id=\"api-token\" required><div class=\"form-text\">Create a token with <code>Zone.DNS:Edit</code> permissions in the Cloudflare dashboard.</div></div><div class=\"mb-3\"><label for=\"zone-id-input\" class=\"form-label\">Zone ID</label> <input type=\"text\" class=\"form-control\" id=\"zone-id-input\" required><div class=\"form-text\">Found in the Cloudflare dashboard under your domain's overview page.</div></div><div class=\"mb-3\"><label for=\"domain-input\" class=\"form-label\">Domain</label> <input type=\"text\" class=\"form-control\" id=\"domain-input\" value=\"mz.uy\" required></div><div class=\"mb-3\"><label for=\"update-period\" class=\"form-label\">Update Frequency</label> <select class=\"form-select\" id=\"update-period\"><!-- Options will be loaded from API --></select></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" id=\"save-config\">Save</button></div></div></div></div>")
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, "<div class=\"modal fade\" id=\"recordModal\" tabindex=\"-1\" aria-labelledby=\"recordModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"recordModalLabel\">Add DNS Record</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"record-form\"><input type=\"hidden\" id=\"record-id\"><div class=\"mb-3\"><label for=\"record-name\" class=\"form-label\">Name</label><div class=\"input-group\"><input type=\"text\" class=\"form-control\" id=\"record-name\" placeholder=\"subdomain\" required> <span class=\"input-group-text\" id=\"domain-suffix\">.mz.uy</span></div><div class=\"form-text\">Use @ for the root domain</div></div><div class=\"mb-3\"><label for=\"record-type\" class=\"form-label\">Type</label> <select class=\"form-select\" id=\"record-type\"><option value=\"A\">A</option> <option value=\"AAAA\">AAAA</option> <option value=\"CNAME\">CNAME</option> <option value=\"TXT\">TXT</option> <option value=\"MX\">MX</option></select></div><div class=\"mb-3\" id=\"content-group\"><label for=\"record-content\" class=\"form-label\">Content</label> <input type=\"text\" class=\"form-control\" id=\"record-content\" required></div><div class=\"mb-3 form-check\" id=\"use-my-ip-group\"><input type=\"checkbox\" class=\"form-check-input\" id=\"use-my-ip\"> <label class=\"form-check-label\" for=\"use-my-ip\">Use my current IP address</label></div><div class=\"mb-3\"><label for=\"record-ttl\" class=\"form-label\">TTL</label> <select class=\"form-select\" id=\"record-ttl\"><option value=\"1\">Auto</option> <option value=\"120\">2 minutes</option> <option value=\"300\">5 minutes</option> <option value=\"600\">10 minutes</option> <option value=\"1800\">30 minutes</option> <option value=\"3600\">1 hour</option> <option value=\"7200\">2 hours</option> <option value=\"18000\">5 hours</option> <option value=\"43200\">12 hours</option> <option value=\"86400\">1 day</option></select></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"record-proxied\"> <label class=\"form-check-label\" for=\"record-proxied\">Proxied through Cloudflare</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" id=\"save-record\">Save</button></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate