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