restore github
Some checks failed
Build, Push, Publish / Build & Release (push) Failing after 1s

This commit is contained in:
2025-12-09 15:37:27 -03:00
parent cab5a87bbe
commit 4368e51bf3
22 changed files with 2183 additions and 1 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git/
.github/
docker/
.dockerignore
CNAME
Dockerfile
README.md
LICENSE

240
.github/workflows/release_build.yml vendored Normal file
View File

@@ -0,0 +1,240 @@
name: Build, Push, Publish
on:
push:
branches:
- main
workflow_dispatch:
schedule:
- cron: '28 5 * * *'
workflow_run:
workflows: ["Sync Repo"]
types:
- completed
jobs:
release:
name: Build & Release
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: 📥 Checkout code with full history and tags
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if any tags exist
id: check_tags_exist
run: |
git fetch --tags
TAG_COUNT=$(git tag | wc -l)
if [ "$TAG_COUNT" -eq 0 ]; then
echo "has_tags=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=v0.0.0" >> "$GITHUB_OUTPUT"
else
echo "has_tags=true" >> "$GITHUB_OUTPUT"
LATEST_TAG=$(git describe --tags --abbrev=0)
echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
fi
- name: Check if meaningful commits exist since latest tag
id: check_commits
run: |
if [ "${{ steps.check_tags_exist.outputs.has_tags }}" = "false" ]; then
# No tags exist, so we should create first release
echo "commit_count=1" >> "$GITHUB_OUTPUT"
CHANGED_FILES=$(git ls-files | grep -v '^manifest.json$' || true)
if [ -n "$CHANGED_FILES" ]; then
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
printf '%s\n' "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
else
echo "changed_files=Initial release" >> "$GITHUB_OUTPUT"
fi
else
LATEST_TAG="${{ steps.check_tags_exist.outputs.latest_tag }}"
CHANGED_FILES="$(git diff --name-only "${LATEST_TAG}..HEAD" | grep -v '^manifest.json$' || true)"
if [ -n "$CHANGED_FILES" ]; then
echo "commit_count=1" >> "$GITHUB_OUTPUT"
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
printf '%s\n' "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
else
echo "commit_count=0" >> "$GITHUB_OUTPUT"
fi
fi
- name: Get latest release tag (from GitHub API)
id: get_latest_release
run: |
LATEST_RELEASE_TAG=$(curl -sL -H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest" | jq -r .tag_name)
if [ -z "$LATEST_RELEASE_TAG" ] || [ "$LATEST_RELEASE_TAG" = "null" ]; then
LATEST_RELEASE_TAG="v1.0.0"
fi
echo "latest_release_tag=$LATEST_RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "latest_release_version=${LATEST_RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
# -------------------------------
# Sync manifest.json to last release version if behind (only when no meaningful commits)
# -------------------------------
- name: 🛠 Ensure manifest.json matches latest release version
if: steps.check_commits.outputs.commit_count == '0'
run: |
if [ -f manifest.json ]; then
MANIFEST_VERSION=$(jq -r '.version // empty' manifest.json)
else
MANIFEST_VERSION=""
fi
LATEST_RELEASE_VERSION="${{ steps.get_latest_release.outputs.latest_release_version }}"
PYTHON_CODE="from packaging import version; \
print(version.parse('$LATEST_RELEASE_VERSION') > version.parse('$MANIFEST_VERSION') if '$MANIFEST_VERSION' else True)"
NEED_UPDATE=$(python3 -c "$PYTHON_CODE")
if [ "$NEED_UPDATE" = "True" ]; then
echo "Updating manifest.json to version $LATEST_RELEASE_VERSION (sync with release)"
jq --arg v "$LATEST_RELEASE_VERSION" '.version = $v' manifest.json > tmp.json && mv tmp.json manifest.json
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add manifest.json
git commit -m "Sync manifest.json to release $LATEST_RELEASE_VERSION [🔄]" || echo "Nothing to commit"
git push origin main || true
else
echo "Manifest.json is already up-to-date with the latest release."
fi
# -------------------------------
# Continue normal workflow if commits exist
# -------------------------------
- name: 📃 Get list of changed files (Markdown bullet list)
if: steps.check_commits.outputs.commit_count != '0'
id: changed_files
run: |
BULLET_LIST="$(printf '%s\n' "${{ steps.check_commits.outputs.changed_files }}" | sed 's/^/- /')"
echo "CHANGED<<EOF" >> "$GITHUB_OUTPUT"
printf '%s\n' "$BULLET_LIST" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
COUNT="$(printf '%s\n' "${{ steps.check_commits.outputs.changed_files }}" | wc -l)"
echo "COUNT=$COUNT" >> "$GITHUB_OUTPUT"
- name: Get manifest version
if: steps.check_commits.outputs.commit_count != '0'
id: get_manifest_version
run: |
if [ -f manifest.json ]; then
MANIFEST_VERSION=$(jq -r '.version // empty' manifest.json)
if [ -z "$MANIFEST_VERSION" ] || [ "$MANIFEST_VERSION" = "null" ]; then
MANIFEST_VERSION="1.0.0"
fi
else
MANIFEST_VERSION="1.0.0"
fi
echo "manifest_version=$MANIFEST_VERSION" >> "$GITHUB_OUTPUT"
- name: Pick base version
if: steps.check_commits.outputs.commit_count != '0'
id: pick_base_version
run: |
LATEST_RELEASE="${{ steps.get_latest_release.outputs.latest_release_version }}"
MANIFEST="${{ steps.get_manifest_version.outputs.manifest_version }}"
BASE_VERSION=$(python3 -c "from packaging import version; \
print(str(max(version.parse('$LATEST_RELEASE'), version.parse('$MANIFEST'))))")
echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT"
- name: 🔢 Determine version
if: steps.check_commits.outputs.commit_count != '0'
id: version
run: |
BASE_VERSION="${{ steps.pick_base_version.outputs.base_version }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION"
COUNT="${{ steps.changed_files.outputs.COUNT }}"
if [ "$COUNT" -ge 5 ]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [ "$COUNT" -ge 3 ]; then
MINOR=$((MINOR + 1))
PATCH=0
else
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
REPO_NAME="$(basename "$GITHUB_REPOSITORY")"
ZIP_NAME="${REPO_NAME}-${NEW_VERSION}.zip"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "ZIP_NAME=$ZIP_NAME" >> "$GITHUB_OUTPUT"
echo "REPO_NAME=$REPO_NAME" >> "$GITHUB_OUTPUT"
- name: 🛠 Update or create manifest.json
if: steps.check_commits.outputs.commit_count != '0'
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
AUTHOR="Ivan Carlos"
VERSION_FILE="manifest.json"
if [ -f "$VERSION_FILE" ]; then
jq --arg v "$VERSION" --arg a "$AUTHOR" \
'.version = $v | .author = $a' "$VERSION_FILE" > tmp.json && mv tmp.json "$VERSION_FILE"
else
echo "{ \"version\": \"$VERSION\", \"author\": \"$AUTHOR\" }" > "$VERSION_FILE"
fi
- name: 💾 Commit and push updated manifest.json
if: steps.check_commits.outputs.commit_count != '0'
run: |
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add manifest.json
git commit -m "Update manifest version to ${{ steps.version.outputs.VERSION }} [▶️]" || echo "Nothing to commit"
git push origin main
- name: 📦 Create ZIP package (excluding certain files)
if: steps.check_commits.outputs.commit_count != '0'
run: |
ZIP_NAME="${{ steps.version.outputs.ZIP_NAME }}"
zip -r "$ZIP_NAME" . -x ".git/*" ".github/*" "docker/*" ".dockerignore" "CNAME" "Dockerfile" "README.md" "LICENSE"
- name: 🚀 Create GitHub Release
if: steps.check_commits.outputs.commit_count != '0'
uses: softprops/action-gh-release@v2
with:
tag_name: "v${{ steps.version.outputs.VERSION }}"
name: "${{ steps.version.outputs.REPO_NAME }} v${{ steps.version.outputs.VERSION }}"
body: |
### Changelog
Files changed in this release:
${{ steps.changed_files.outputs.CHANGED }}
files: ${{ steps.version.outputs.ZIP_NAME }}
# ----- Docker steps -----
- name: 🔍 Check if Dockerfile exists
if: steps.check_commits.outputs.commit_count != '0'
id: dockerfile_check
run: |
if [ -f Dockerfile ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: 🛠 Set up Docker Buildx
if: steps.check_commits.outputs.commit_count != '0' && steps.dockerfile_check.outputs.exists == 'true'
uses: docker/setup-buildx-action@v3
- name: 🔐 Login to GitHub Container Registry
if: steps.check_commits.outputs.commit_count != '0' && steps.dockerfile_check.outputs.exists == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 🐳 Build and Push Docker image
if: steps.check_commits.outputs.commit_count != '0' && steps.dockerfile_check.outputs.exists == 'true'
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest

42
.github/workflows/sync_repo.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Sync Repo
on:
workflow_dispatch:
schedule:
- cron: '34 3 * * *' # 03:34 UTC == 00:34 BRT
permissions:
contents: write
jobs:
download-aws-sdk:
runs-on: ubuntu-latest
permissions:
contents: write
actions: write # needed to dispatch another workflow
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download AWS SDK PHAR
run: |
mkdir -p vendor
wget https://github.com/aws/aws-sdk-php/releases/latest/download/aws.phar -O vendor/aws.phar
- name: Commit and push changes
id: commit_step
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add vendor/aws.phar
# If there are changes, commit & push; set output flag accordingly
if git diff --quiet && git diff --staged --quiet; then
echo "No changes to commit"
echo "changes_committed=false" >> $GITHUB_OUTPUT
else
git commit -m "Update AWS SDK PHAR [▶️]"
git push origin HEAD:main
echo "changes_committed=true" >> $GITHUB_OUTPUT
fi

78
.github/workflows/update_readme.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Update README
# Allow GitHub Actions to commit and push changes
permissions:
contents: write
on:
workflow_dispatch:
schedule:
- cron: '0 4 * * *' # Every day at 4 AM UTC
jobs:
update-readme:
runs-on: ubuntu-latest
env:
SOURCE_REPO: ivancarlosti/.github
SOURCE_BRANCH: main
steps:
- name: Checkout current repository
uses: actions/checkout@v4
- name: Checkout source README template
uses: actions/checkout@v4
with:
repository: ${{ env.SOURCE_REPO }}
ref: ${{ env.SOURCE_BRANCH }}
path: source_readme
- name: Update README.md (buttons and footer)
run: |
set -e
REPO_NAME="${GITHUB_REPOSITORY##*/}"
# --- Extract buttons block from source ---
BUTTONS=$(awk '/<!-- buttons -->/{flag=1;next}/<!-- endbuttons -->/{flag=0}flag' source_readme/README.md)
BUTTONS_UPDATED=$(echo "$BUTTONS" | sed "s/\.github/${REPO_NAME}/g")
# --- Extract footer block from source (everything from <!-- footer --> onward) ---
FOOTER=$(awk '/<!-- footer -->/{flag=1}flag' source_readme/README.md)
# --- Replace buttons section in README.md ---
UPDATED=$(awk -v buttons="$BUTTONS_UPDATED" '
BEGIN { skip=0 }
/<!-- buttons -->/ {
print
print buttons
skip=1
next
}
/<!-- endbuttons -->/ && skip {
print
skip=0
next
}
!skip { print }
' README.md)
# --- Replace everything after <!-- footer --> with FOOTER ---
echo "$UPDATED" | awk -v footer="$FOOTER" '
/<!-- footer -->/ {
print footer
found=1
exit
}
{ print }
' > README.tmp && mv README.tmp README.md
- name: Remove source_readme from git index
run: git rm --cached -r source_readme || true
- name: Commit and push changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
file_pattern: README.md
commit_message: "Sync README from template [▶️]"
branch: ${{ github.ref_name }}

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
FROM php:8.4-fpm-alpine
# Install Nginx and MariaDB client; install PHP extensions (mysqli) and clean up
RUN apk add --no-cache --update nginx \
&& docker-php-ext-install mysqli \
&& rm -rf /var/cache/apk/* /tmp/*
# Copy your application code
COPY . /var/www/html/
# Create nginx.conf directly in the Docker build
RUN printf '%s\n' \
'worker_processes auto;' \
'' \
'events { worker_connections 1024; }' \
'' \
'http {' \
' include mime.types;' \
' default_type application/octet-stream;' \
'' \
' sendfile on;' \
'' \
' server {' \
' listen 80;' \
' server_name localhost;' \
' root /var/www/html/public;' \
'' \
' index index.php index.html;' \
'' \
' location / {' \
' try_files $uri $uri/ /index.php?$query_string;' \
' }' \
'' \
' location ~ \.php$ {' \
' include fastcgi_params;' \
' fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;' \
' fastcgi_index index.php;' \
' fastcgi_pass 127.0.0.1:9000;' \
' }' \
' }' \
'}' \
> /etc/nginx/nginx.conf
# Make sure Nginx and PHP-FPM can access/serve project files
RUN chown -R www-data:www-data /var/www/html
EXPOSE 80
CMD php-fpm -D && nginx -g 'daemon off;'

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ivan Carlos de Almeida
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

123
README.md
View File

@@ -1,2 +1,123 @@
# ddnsonroute53 # DDNS on Route53
Router friendly client to update AWS Route53 entries for Dynamic DNS funtionality
<!-- buttons -->
[![Stars](https://img.shields.io/github/stars/ivancarlosti/ddnsonroute53?label=⭐%20Stars&color=gold&style=flat)](https://github.com/ivancarlosti/ddnsonroute53/stargazers)
[![Watchers](https://img.shields.io/github/watchers/ivancarlosti/ddnsonroute53?label=Watchers&style=flat&color=red)](https://github.com/sponsors/ivancarlosti)
[![Forks](https://img.shields.io/github/forks/ivancarlosti/ddnsonroute53?label=Forks&style=flat&color=ff69b4)](https://github.com/sponsors/ivancarlosti)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/ivancarlosti/ddnsonroute53?label=Activity)](https://github.com/ivancarlosti/ddnsonroute53/pulse)
[![GitHub Issues](https://img.shields.io/github/issues/ivancarlosti/ddnsonroute53?label=Issues&color=orange)](https://github.com/ivancarlosti/ddnsonroute53/issues)
[![License](https://img.shields.io/github/license/ivancarlosti/ddnsonroute53?label=License)](LICENSE)
[![GitHub last commit](https://img.shields.io/github/last-commit/ivancarlosti/ddnsonroute53?label=Last%20Commit)](https://github.com/ivancarlosti/ddnsonroute53/commits)
[![Security](https://img.shields.io/badge/Security-View%20Here-purple)](https://github.com/ivancarlosti/ddnsonroute53/security)
[![Code of Conduct](https://img.shields.io/badge/Code%20of%20Conduct-2.1-4baaaa)](https://github.com/ivancarlosti/ddnsonroute53?tab=coc-ov-file)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/ivancarlosti?label=GitHub%20Sponsors&color=ffc0cb)][sponsor]
<!-- endbuttons -->
## Requirement:
* [Docker Compose](https://docs.docker.com/engine/install/)
* MySQL/MariaDB
* Keycloak for SSO
## Hosting instructions (Docker Compose with PHP + nginx + mysqli):
* Download `/docker` files on your server, example:
```
curl -o .env https://raw.githubusercontent.com/ivancarlosti/ddnsonroute53/main/docker/.env
curl -o docker-compose.yml https://raw.githubusercontent.com/ivancarlosti/ddnsonroute53/main/docker/docker-compose.yml
```
* Edit both `.env`, `docker-compose.yml` files
* This Docker Compose contains only PHP + nginx + mysqli as webserver, you can use a reverse proxy for SSL, default exposed port is `5666`, an external MySQL/MariaDB is required
* Start Docker Compose, example:
```
docker compose pull && docker compose up -d
```
## Service setup:
* Run `setup.php` on browser to set username and password
* Run `index.php` on browser to login
* Access `Manage AWS Credentials` menu and fill required fields
* Access `Manage DDNS Entries` to add, edit and delete DDNS entries
* (optional) Access `Manage Users` to create new users, add/edit reCAPTCHA keys
* (optional) Access `View All Logs` to check last 30 days of entries created and/or updated
IAM required policy, remember to update `YOURZONEID` value to related domain zone ID and `subdomain.example.com.` to your domain or subdomain for service usage:
```
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets"
],
"Resource": "arn:aws:route53:::hostedzone/YOURZONEID",
"Condition": {
"ForAllValues:StringLike": {
"route53:ChangeResourceRecordSetsNormalizedRecordNames": [
"*.subdomain.example.com."
]
}
}
},
{
"Effect": "Allow",
"Action": "route53:GetChange",
"Resource": "*"
}
]
}
```
## DDNS cURL update format:
To simplify the process, `[FQDN]` is also used as login for basic auth purposes.
Example: `https://[FQDN]:[PASSWORD]@subdomain.example.com/update.php?hostname=[FQDN]&myip=[IP]`
### Tested DDNS Custom URL:
TP-Link Omada Update URL:
* `https://[USERNAME]:[PASSWORD]@subdomain.example.com/update.php?hostname=[DOMAIN]&myip=[IP]`
## To Do:
* HTML beautification
* Build releases using Compose to populate AWS SDK dinamically
## Hosting note:
Using PHP with the Suhosin patch is not recommended, but is common on some Ubuntu and Debian distributions. To modify `suhosin.ini`, add the following line.
```
suhosin.executor.include.whitelist = phar
```
<!-- footer -->
---
## 🧑‍💻 Consulting and technical support
* For personal support and queries, please submit a new issue to have it addressed.
* For commercial related questions, please [**contact me**][ivancarlos] for consulting costs.
## 🩷 Project support
| If you found this project helpful, consider |
| :---: |
[**buying me a coffee**][buymeacoffee], [**donate by paypal**][paypal], [**sponsor this project**][sponsor] or just [**leave a star**](../..)⭐
|Thanks for your support, it is much appreciated!|
[cc]: https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/adding-a-code-of-conduct-to-your-project
[contributing]: https://docs.github.com/en/articles/setting-guidelines-for-repository-contributors
[security]: https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository
[support]: https://docs.github.com/en/articles/adding-support-resources-to-your-project
[it]: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository#configuring-the-template-chooser
[prt]: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository
[funding]: https://docs.github.com/en/articles/displaying-a-sponsor-button-in-your-repository
[ivancarlos]: https://ivancarlos.it
[buymeacoffee]: https://www.buymeacoffee.com/ivancarlos
[paypal]: https://icc.gg/donate
[sponsor]: https://github.com/sponsors/ivancarlosti

23
dbconfig.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
// dbconfig.php
define('DB_SERVER', getenv('DB_SERVER') ?: 'localhost');
define('DB_NAME', getenv('DB_NAME') ?: 'dns');
define('DB_USERNAME', getenv('DB_USERNAME') ?: 'dns');
define('DB_PASSWORD', getenv('DB_PASSWORD') ?: 'password');
// Keycloak Configuration
define('KEYCLOAK_BASE_URL', getenv('KEYCLOAK_BASE_URL') ?: 'https://keycloak.example.com/auth');
define('KEYCLOAK_REALM', getenv('KEYCLOAK_REALM') ?: 'myrealm');
define('KEYCLOAK_CLIENT_ID', getenv('KEYCLOAK_CLIENT_ID') ?: 'myclient');
define('KEYCLOAK_CLIENT_SECRET', getenv('KEYCLOAK_CLIENT_SECRET') ?: 'mysecret');
define('KEYCLOAK_REDIRECT_URI', getenv('KEYCLOAK_REDIRECT_URI') ?: 'http://localhost/index.php');
// Create the connection (EXACTLY as in your original)
$link = @new mysqli(DB_SERVER, DB_USERNAME, DB_PASSWORD, DB_NAME);
// Optional: Add error logging if connection fails
if ($link->connect_error) {
error_log("Database connection failed: " . $link->connect_error);
}
?>

12
docker/.env Normal file
View File

@@ -0,0 +1,12 @@
# Database Settings
DB_SERVER=host.docker.internal
DB_NAME=YOURDBNAME
DB_USERNAME=YOURDBUSERNAME
DB_PASSWORD=YOURDBPASSWORD
# Keycloak Settings
KEYCLOAK_BASE_URL=https://sso.example.com
KEYCLOAK_REALM=YOURSSORealm
KEYCLOAK_CLIENT_ID=ddnsonroute53
KEYCLOAK_CLIENT_SECRET=YOURKEYCLOAKCLIENTSECRET
KEYCLOAK_REDIRECT_URI=https://ddnsonroute53.example.com/index.php

22
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
name: ddnsonroute53
services:
ddnsonroute53:
image: ghcr.io/ivancarlosti/ddnsonroute53:latest
env_file: .env
container_name: ddnsonroute53
restart: unless-stopped
ports:
- "5666:80" # expose port 5666 to host
environment:
- DB_SERVER=${DB_SERVER}
- DB_NAME=${DB_NAME}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
- KEYCLOAK_BASE_URL=${KEYCLOAK_BASE_URL}
- KEYCLOAK_REALM=${KEYCLOAK_REALM}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
- KEYCLOAK_REDIRECT_URI=${KEYCLOAK_REDIRECT_URI}
extra_hosts:
- host.docker.internal:host-gateway

4
manifest.json Normal file
View File

@@ -0,0 +1,4 @@
{
"version": "3.0.5",
"author": "Ivan Carlos"
}

42
public/dashboard.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
session_start();
if(!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true){
header('location: index.php');
exit;
}
// Get the current domain of the hosted page
$domain = $_SERVER['HTTP_HOST']; // This will automatically get the domain of the hosted page
// Construct the cURL command
$curlCommand = "https://[USERNAME]:[PASSWORD]@$domain/update.php?hostname=[DOMAIN]&myip=[IP]";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<div class="card">
<h1>Welcome, <?php echo htmlspecialchars($_SESSION['username']); ?>!</h1>
<div class="flex gap-2 mt-4">
<a href="manage_users.php" class="btn">Manage Users</a>
<a href="manage_aws.php" class="btn">Manage AWS Credentials</a>
<a href="manage_ddns.php" class="btn">Manage DDNS Entries</a>
<a href="view_logs.php" class="btn">View All Logs</a>
<a href="index.php?logout=true" class="btn btn-danger">Logout</a>
</div>
</div>
<div class="card">
<h2>DDNS Update cURL Command</h2>
<p>Use the following cURL command to update your DDNS entry:</p>
<pre style="background: rgba(0,0,0,0.3); padding: 1rem; border-radius: 0.5rem; overflow-x: auto;"><?php echo htmlspecialchars($curlCommand); ?></pre>
</div>
</div>
</body>
</html>

145
public/index.php Normal file
View File

@@ -0,0 +1,145 @@
<?php
session_start();
error_reporting(0);
ini_set('display_errors', 0);
function handleFatalError()
{
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
die("A fatal error occurred. Please check your configuration.");
}
}
register_shutdown_function('handleFatalError');
include '../dbconfig.php';
if ($link === null || $link->connect_error) {
die("Database connection failed.");
}
// Helper to check table existence
function tableExists($link, $tableName)
{
$sql = "SHOW TABLES LIKE '$tableName'";
$result = $link->query($sql);
return $result->num_rows > 0;
}
if (!tableExists($link, 'users')) {
header('Location: setup.php');
exit;
}
// --- Keycloak SSO Logic ---
// 1. Handle Logout
if (isset($_GET['logout'])) {
session_destroy();
$logoutUrl = KEYCLOAK_BASE_URL . "/realms/" . KEYCLOAK_REALM . "/protocol/openid-connect/logout?redirect_uri=" . urlencode(KEYCLOAK_REDIRECT_URI);
header("Location: $logoutUrl");
exit;
}
// 2. Handle OAuth Callback
if (isset($_GET['code'])) {
$code = $_GET['code'];
$tokenUrl = KEYCLOAK_BASE_URL . "/realms/" . KEYCLOAK_REALM . "/protocol/openid-connect/token";
$data = [
'grant_type' => 'authorization_code',
'client_id' => KEYCLOAK_CLIENT_ID,
'client_secret' => KEYCLOAK_CLIENT_SECRET,
'code' => $code,
'redirect_uri' => KEYCLOAK_REDIRECT_URI
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $tokenUrl);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$tokenData = json_decode($response, true);
if (isset($tokenData['access_token'])) {
// Get User Info
$userInfoUrl = KEYCLOAK_BASE_URL . "/realms/" . KEYCLOAK_REALM . "/protocol/openid-connect/userinfo";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $userInfoUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . $tokenData['access_token']]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$userInfoResponse = curl_exec($ch);
curl_close($ch);
$userInfo = json_decode($userInfoResponse, true);
$email = $userInfo['email'] ?? $userInfo['preferred_username'] ?? null;
if ($email) {
// Check if user exists in local DB
$stmt = $link->prepare("SELECT id, username FROM users WHERE username = ?");
$stmt->bind_param("s", $email);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 1) {
$user = $result->fetch_assoc();
$_SESSION['loggedin'] = true;
$_SESSION['id'] = $user['id'];
$_SESSION['username'] = $user['username'];
header("Location: dashboard.php");
exit;
} else {
$error = "Access Denied: User not found in local database.";
}
$stmt->close();
} else {
$error = "Could not retrieve email from identity provider.";
}
} else {
$error = "Failed to authenticate with SSO.";
}
}
// 3. Generate Login URL
$loginUrl = KEYCLOAK_BASE_URL . "/realms/" . KEYCLOAK_REALM . "/protocol/openid-connect/auth" .
"?client_id=" . KEYCLOAK_CLIENT_ID .
"&response_type=code" .
"&redirect_uri=" . urlencode(KEYCLOAK_REDIRECT_URI) .
"&scope=openid email profile";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - DDNS Manager</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="login-container">
<div class="card login-card text-center">
<h1>DDNS Manager</h1>
<p class="mb-4" style="color: #94a3b8;">Secure Access Control</p>
<?php if (isset($error)): ?>
<div class="alert alert-error">
<?php echo htmlspecialchars($error); ?>
</div>
<?php endif; ?>
<a href="<?php echo htmlspecialchars($loginUrl); ?>" class="btn"
style="width: 100%; box-sizing: border-box;">
Login with SSO
</a>
</div>
</div>
</body>
</html>

126
public/manage_aws.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
session_start();
if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
header('location: index.php');
exit;
}
include '../dbconfig.php';
// Handle form submission to update AWS credentials
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$region = $_POST['region'];
$access_key_id = $_POST['access_key_id'];
$secret_access_key = $_POST['secret_access_key'];
$hosted_zone_id = $_POST['hosted_zone_id'];
$approved_fqdn = $_POST['approved_fqdn'];
// Check if there's already data in the table
$check_sql = "SELECT id FROM aws_credentials LIMIT 1";
$check_result = $link->query($check_sql);
if ($check_result->num_rows > 0) {
// Update existing record
$sql = "UPDATE aws_credentials SET region = ?, access_key_id = ?, secret_access_key = ?, hosted_zone_id = ?, approved_fqdn = ? WHERE id = 1";
} else {
// Insert new record
$sql = "INSERT INTO aws_credentials (region, access_key_id, secret_access_key, hosted_zone_id, approved_fqdn) VALUES (?, ?, ?, ?, ?)";
}
if ($stmt = $link->prepare($sql)) {
$stmt->bind_param("sssss", $region, $access_key_id, $secret_access_key, $hosted_zone_id, $approved_fqdn);
if ($stmt->execute()) {
$success = "AWS credentials updated successfully!";
} else {
$error = "Error updating AWS credentials: " . $stmt->error;
}
$stmt->close();
} else {
$error = "Error preparing SQL statement: " . $link->error;
}
}
// Fetch current AWS credentials from the database
$sql = "SELECT region, access_key_id, secret_access_key, hosted_zone_id, approved_fqdn FROM aws_credentials LIMIT 1";
$current_credentials = [];
if ($result = $link->query($sql)) {
if ($result->num_rows > 0) {
$current_credentials = $result->fetch_assoc();
}
$result->free();
}
$link->close();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage AWS Credentials</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Manage AWS Credentials</h1>
<?php if (isset($error)): ?>
<div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<?php if (isset($success)): ?>
<div class="alert alert-success"><?php echo htmlspecialchars($success); ?></div>
<?php endif; ?>
<div class="card">
<?php if (!empty($current_credentials)): ?>
<h2>Current AWS Credentials</h2>
<ul>
<li><strong>Region:</strong> <?php echo htmlspecialchars($current_credentials['region']); ?></li>
<li><strong>Access Key ID:</strong>
<?php echo htmlspecialchars($current_credentials['access_key_id']); ?></li>
<li><strong>Secret Access Key:</strong>
<?php echo htmlspecialchars($current_credentials['secret_access_key']); ?></li>
<li><strong>Hosted Zone ID:</strong>
<?php echo htmlspecialchars($current_credentials['hosted_zone_id']); ?></li>
<li><strong>Approved FQDN:</strong>
<?php echo htmlspecialchars($current_credentials['approved_fqdn']); ?></li>
</ul>
<?php else: ?>
<p>No AWS credentials found in the database.</p>
<?php endif; ?>
</div>
<div class="card">
<h2>Update AWS Credentials</h2>
<form method="post">
<label>Region:</label>
<input type="text" name="region"
value="<?php echo htmlspecialchars($current_credentials['region'] ?? ''); ?>" required>
<label>Access Key ID:</label>
<input type="text" name="access_key_id"
value="<?php echo htmlspecialchars($current_credentials['access_key_id'] ?? ''); ?>" required>
<label>Secret Access Key:</label>
<input type="text" name="secret_access_key"
value="<?php echo htmlspecialchars($current_credentials['secret_access_key'] ?? ''); ?>" required>
<label>Hosted Zone ID:</label>
<input type="text" name="hosted_zone_id"
value="<?php echo htmlspecialchars($current_credentials['hosted_zone_id'] ?? ''); ?>" required>
<label>Approved FQDN:</label>
<input type="text" name="approved_fqdn"
value="<?php echo htmlspecialchars($current_credentials['approved_fqdn'] ?? ''); ?>" required>
<input type="submit" value="Update Credentials">
</form>
</div>
<p><a href="dashboard.php">Back to Dashboard</a></p>
</div>
</body>
</html>

397
public/manage_ddns.php Normal file
View File

@@ -0,0 +1,397 @@
<?php
session_start();
if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
header('location: index.php');
exit;
}
include '../dbconfig.php';
require '../vendor/aws.phar';
use Aws\Route53\Route53Client;
use Aws\Exception\AwsException;
// Clean up logs older than 30 days
$cleanup_sql = "CALL CleanupOldLogs()";
if ($cleanup_stmt = $link->prepare($cleanup_sql)) {
$cleanup_stmt->execute();
$cleanup_stmt->close();
}
// Fetch the approved FQDN from the database
$approved_fqdn = '';
$aws_sql = "SELECT approved_fqdn, region, access_key_id, secret_access_key, hosted_zone_id FROM aws_credentials LIMIT 1";
if ($aws_result = $link->query($aws_sql)) {
if ($aws_result->num_rows > 0) {
$row = $aws_result->fetch_assoc();
$approved_fqdn = $row['approved_fqdn'];
$region = $row['region'];
$access_key_id = $row['access_key_id'];
$secret_access_key = $row['secret_access_key'];
$hosted_zone_id = $row['hosted_zone_id'];
} else {
die("No AWS credentials found in the database.");
}
$aws_result->free();
} else {
die("Error fetching AWS credentials: " . $link->error);
}
// Initialize the Route53 client
try {
$route53 = new Route53Client([
'version' => 'latest',
'region' => $region,
'credentials' => [
'key' => $access_key_id,
'secret' => $secret_access_key,
],
]);
} catch (AwsException $e) {
die("Error initializing Route53 client: " . $e->getMessage());
}
// Handle form submission to add a new DDNS entry
if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_POST['add_ddns'])) {
$ddns_fqdn = $_POST['ddns_fqdn'];
$ddns_password = $_POST['ddns_password'];
$initial_ip = $_POST['initial_ip'];
$ttl = $_POST['ttl'];
// Validate input
if (empty($ddns_fqdn) || empty($ddns_password) || empty($initial_ip) || empty($ttl)) {
$error = "DDNS FQDN, password, initial IP, and TTL are required.";
} elseif (!filter_var($initial_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$error = "Invalid IPv4 address.";
} else {
// Check if the DDNS FQDN is a subdomain of the approved FQDN
if (strpos($ddns_fqdn, $approved_fqdn) === false || !preg_match('/^[a-zA-Z0-9-]+\.' . preg_quote($approved_fqdn, '/') . '$/', $ddns_fqdn)) {
$error = "DDNS FQDN must be a subdomain of $approved_fqdn.";
} else {
// Check if the DDNS entry already exists
$check_sql = "SELECT id FROM ddns_entries WHERE ddns_fqdn = ?";
if ($check_stmt = $link->prepare($check_sql)) {
$check_stmt->bind_param("s", $ddns_fqdn);
$check_stmt->execute();
$check_stmt->store_result();
if ($check_stmt->num_rows > 0) {
$error = "DDNS entry with this FQDN already exists.";
} else {
// Prepare the DNS record
$changeBatch = [
'Changes' => [
[
'Action' => 'UPSERT',
'ResourceRecordSet' => [
'Name' => $ddns_fqdn . '.',
'Type' => 'A',
'TTL' => (int)$ttl,
'ResourceRecords' => [
[
'Value' => $initial_ip,
],
],
],
],
],
];
try {
// Create the DNS record in Route53
$result = $route53->changeResourceRecordSets([
'HostedZoneId' => $hosted_zone_id,
'ChangeBatch' => $changeBatch,
]);
// Insert the new DDNS entry into the database
$insert_sql = "INSERT INTO ddns_entries (ddns_fqdn, ddns_password, last_ipv4, ttl) VALUES (?, ?, ?, ?)";
if ($insert_stmt = $link->prepare($insert_sql)) {
$insert_stmt->bind_param("sssi", $ddns_fqdn, $ddns_password, $initial_ip, $ttl);
if ($insert_stmt->execute()) {
$ddns_entry_id = $insert_stmt->insert_id;
// Log the action
$action = 'add';
$ip_address = $_SERVER['REMOTE_ADDR'];
$details = "Added DDNS entry with FQDN: $ddns_fqdn, Initial IP: $initial_ip, TTL: $ttl";
$log_sql = "INSERT INTO ddns_logs (ddns_entry_id, action, ip_address, details) VALUES (?, ?, ?, ?)";
if ($log_stmt = $link->prepare($log_sql)) {
$log_stmt->bind_param("isss", $ddns_entry_id, $action, $ip_address, $details);
$log_stmt->execute();
$log_stmt->close();
}
$success = "DDNS entry '$ddns_fqdn' added successfully!";
} else {
$error = "Error adding DDNS entry: " . $insert_stmt->error;
}
$insert_stmt->close();
}
} catch (AwsException $e) {
$error = "Error updating Route53: " . $e->getAwsErrorMessage();
}
}
$check_stmt->close();
}
}
}
}
// Handle IP and TTL update for a DDNS entry
if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_POST['update_ip'])) {
$ddns_id = $_POST['ddns_id'];
$new_ip = $_POST['new_ip'];
$new_ttl = $_POST['new_ttl'];
// Validate input
if (empty($new_ip) || empty($new_ttl)) {
$error = "IP and TTL are required.";
} elseif (!filter_var($new_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$error = "Invalid IPv4 address.";
} else {
// Fetch the DDNS entry
$fetch_sql = "SELECT ddns_fqdn FROM ddns_entries WHERE id = ?";
if ($fetch_stmt = $link->prepare($fetch_sql)) {
$fetch_stmt->bind_param("i", $ddns_id);
$fetch_stmt->execute();
$fetch_stmt->store_result();
$fetch_stmt->bind_result($ddns_fqdn);
$fetch_stmt->fetch();
$fetch_stmt->close();
// Prepare the DNS record update
$changeBatch = [
'Changes' => [
[
'Action' => 'UPSERT',
'ResourceRecordSet' => [
'Name' => $ddns_fqdn . '.',
'Type' => 'A',
'TTL' => (int)$new_ttl,
'ResourceRecords' => [
[
'Value' => $new_ip,
],
],
],
],
],
];
try {
// Update the DNS record in Route53
$result = $route53->changeResourceRecordSets([
'HostedZoneId' => $hosted_zone_id,
'ChangeBatch' => $changeBatch,
]);
// Update the IP and TTL in the database
$update_sql = "UPDATE ddns_entries SET last_ipv4 = ?, ttl = ?, last_update = NOW() WHERE id = ?";
if ($update_stmt = $link->prepare($update_sql)) {
$update_stmt->bind_param("sii", $new_ip, $new_ttl, $ddns_id);
if ($update_stmt->execute()) {
// Log the action
$action = 'update';
$ip_address = $_SERVER['REMOTE_ADDR'];
$details = "Updated IP: $new_ip, TTL: $new_ttl";
$log_sql = "INSERT INTO ddns_logs (ddns_entry_id, action, ip_address, details) VALUES (?, ?, ?, ?)";
if ($log_stmt = $link->prepare($log_sql)) {
$log_stmt->bind_param("isss", $ddns_id, $action, $ip_address, $details);
$log_stmt->execute();
$log_stmt->close();
}
$success = "IP and TTL updated successfully for '$ddns_fqdn'!";
} else {
$error = "Error updating IP and TTL: " . $update_stmt->error;
}
$update_stmt->close();
}
} catch (AwsException $e) {
$error = "Error updating Route53: " . $e->getAwsErrorMessage();
}
}
}
}
// Handle DDNS entry deletion
if (isset($_GET['delete'])) {
$ddns_id = $_GET['delete'];
// Fetch the DDNS entry to get the FQDN and last IP
$fetch_sql = "SELECT ddns_fqdn, last_ipv4, ttl FROM ddns_entries WHERE id = ?";
if ($fetch_stmt = $link->prepare($fetch_sql)) {
$fetch_stmt->bind_param("i", $ddns_id);
$fetch_stmt->execute();
$fetch_stmt->store_result();
$fetch_stmt->bind_result($ddns_fqdn, $last_ipv4, $ttl);
$fetch_stmt->fetch();
$fetch_stmt->close();
// Prepare the DNS record deletion
$changeBatch = [
'Changes' => [
[
'Action' => 'DELETE',
'ResourceRecordSet' => [
'Name' => $ddns_fqdn . '.',
'Type' => 'A',
'TTL' => (int)$ttl,
'ResourceRecords' => [
[
'Value' => $last_ipv4,
],
],
],
],
],
];
try {
// Delete the DNS record in Route53
$result = $route53->changeResourceRecordSets([
'HostedZoneId' => $hosted_zone_id,
'ChangeBatch' => $changeBatch,
]);
// Delete the DDNS entry from the database
$delete_sql = "DELETE FROM ddns_entries WHERE id = ?";
if ($delete_stmt = $link->prepare($delete_sql)) {
$delete_stmt->bind_param("i", $ddns_id);
if ($delete_stmt->execute()) {
$success = "DDNS entry deleted successfully and Route53 record removed!";
} else {
$error = "Error deleting DDNS entry: " . $delete_stmt->error;
}
$delete_stmt->close();
}
} catch (AwsException $e) {
$error = "Error updating Route53: " . $e->getAwsErrorMessage();
}
}
}
// Fetch all DDNS entries from the database
$sql = "SELECT id, ddns_fqdn, ddns_password, last_ipv4, ttl, last_update FROM ddns_entries";
$ddns_entries = [];
if ($result = $link->query($sql)) {
while ($row = $result->fetch_assoc()) {
$ddns_entries[] = $row;
}
$result->free();
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage DDNS Entries</title>
<link rel="stylesheet" href="style.css">
<script src="table_sort.js"></script>
<style>
th.sortable {
cursor: pointer;
position: relative;
}
th.sortable:hover {
background-color: #f0f0f0;
}
th.sortable::after {
content: '↕';
position: absolute;
right: 8px;
opacity: 0.3;
}
th.sortable.asc::after {
content: '↑';
opacity: 1;
}
th.sortable.desc::after {
content: '↓';
opacity: 1;
}
</style>
</head>
<body>
<div class="container">
<h1>Manage DDNS Entries</h1>
<?php if (isset($error)): ?>
<div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<?php if (isset($success)): ?>
<div class="alert alert-success"><?php echo htmlspecialchars($success); ?></div>
<?php endif; ?>
<div class="card">
<h2>Add New DDNS Entry</h2>
<form method="post">
<label>DDNS FQDN:</label>
<input type="text" name="ddns_fqdn" required placeholder="subdomain.<?php echo htmlspecialchars($approved_fqdn); ?>">
<label>DDNS Password:</label>
<input type="password" name="ddns_password" required>
<label>Initial IP:</label>
<input type="text" name="initial_ip" required value="<?php echo $_SERVER['REMOTE_ADDR']; ?>">
<label>TTL (Time to Live):</label>
<input type="number" name="ttl" min="1" required value="300">
<input type="submit" name="add_ddns" value="Add DDNS Entry">
</form>
</div>
<div class="card">
<h2>DDNS Entries</h2>
<div class="table-responsive">
<table id="ddnsTable">
<thead>
<tr>
<th class="sortable" data-type="string">FQDN</th>
<th class="sortable" data-type="string">Password</th>
<th class="sortable" data-type="string">Last IPv4</th>
<th class="sortable" data-type="number">TTL</th>
<th class="sortable" data-type="string">Last Update</th>
<th>Update IP/TTL</th>
<th>Logs</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($ddns_entries as $entry): ?>
<tr>
<td><?php echo htmlspecialchars($entry['ddns_fqdn']); ?></td>
<td><?php echo htmlspecialchars($entry['ddns_password']); ?></td>
<td><?php echo htmlspecialchars($entry['last_ipv4']); ?></td>
<td><?php echo htmlspecialchars($entry['ttl']); ?></td>
<td><?php echo htmlspecialchars($entry['last_update']); ?></td>
<td>
<form method="post" style="display:inline; max-width: none;">
<input type="hidden" name="ddns_id" value="<?php echo $entry['id']; ?>">
<div class="flex gap-2">
<input type="text" name="new_ip" placeholder="New IP" required style="width: 120px;">
<input type="number" name="new_ttl" placeholder="TTL" min="1" required style="width: 80px;">
<input type="submit" name="update_ip" value="Update" style="padding: 0.5rem;">
</div>
</form>
</td>
<td>
<a href="view_logs.php?ddns_id=<?php echo $entry['id']; ?>" class="btn" style="padding: 0.25rem 0.5rem; font-size: 0.875rem;">Logs</a>
</td>
<td>
<a href="manage_ddns.php?delete=<?php echo $entry['id']; ?>" onclick="return confirm('Are you sure you want to delete this DDNS entry?');" class="btn btn-danger" style="padding: 0.25rem 0.5rem; font-size: 0.875rem;">Delete</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<p><a href="dashboard.php">Back to Dashboard</a></p>
</div>
</body>
</html>

158
public/manage_users.php Normal file
View File

@@ -0,0 +1,158 @@
<?php
session_start();
if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
header('location: index.php');
exit;
}
include '../dbconfig.php';
// Fetch the logged-in user's ID and username
$logged_in_user_id = $_SESSION['id'];
$logged_in_username = $_SESSION['username'];
// Handle form submission to add a new user (admin-only feature)
if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_POST['add_user'])) {
$username = $_POST['username'];
// Password is now optional for SSO users
$password = !empty($_POST['password']) ? $_POST['password'] : null;
// Validate input
if (empty($username)) {
$error = "Username (Email) is required.";
} else {
// Check if the username is a valid email address
if (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
$error = "Invalid email address.";
} else {
// Check if the user already exists
$check_sql = "SELECT id FROM users WHERE username = ?";
if ($check_stmt = $link->prepare($check_sql)) {
$check_stmt->bind_param("s", $username);
$check_stmt->execute();
$check_stmt->store_result();
if ($check_stmt->num_rows > 0) {
$error = "User with this email already exists.";
} else {
// Insert the new user
$password_hash = $password ? password_hash($password, PASSWORD_DEFAULT) : null;
$insert_sql = "INSERT INTO users (username, password_hash) VALUES (?, ?)";
if ($insert_stmt = $link->prepare($insert_sql)) {
$insert_stmt->bind_param("ss", $username, $password_hash);
if ($insert_stmt->execute()) {
$success = "User '$username' added successfully!";
} else {
$error = "Error adding user: " . $insert_stmt->error;
}
$insert_stmt->close();
}
}
$check_stmt->close();
}
}
}
}
// Handle user deletion (admin-only feature)
if (isset($_GET['delete'])) {
$user_id = $_GET['delete'];
// Prevent the logged-in user from deleting themselves
if ($user_id == $logged_in_user_id) {
$error = "You cannot delete your own account.";
} else {
$sql = "DELETE FROM users WHERE id = ?";
if ($stmt = $link->prepare($sql)) {
$stmt->bind_param("i", $user_id);
if ($stmt->execute()) {
$success = "User deleted successfully!";
} else {
$error = "Error deleting user: " . $stmt->error;
}
$stmt->close();
}
}
}
// Fetch all users from the database
$sql = "SELECT id, username, password_hash FROM users";
$users = [];
if ($result = $link->query($sql)) {
while ($row = $result->fetch_assoc()) {
$users[] = $row;
}
$result->free();
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Users</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Manage Users</h1>
<?php if (isset($error)): ?>
<div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<?php if (isset($success)): ?>
<div class="alert alert-success"><?php echo htmlspecialchars($success); ?></div>
<?php endif; ?>
<div class="card">
<h2>Add New User</h2>
<p>Add a user to allow them to login via Keycloak SSO. Password is optional and only needed if you plan to support local login (legacy).</p>
<form method="post">
<label>Email address:</label>
<input type="email" name="username" required placeholder="user@example.com">
<label>Password (Optional):</label>
<input type="password" name="password" placeholder="Leave empty for SSO-only users">
<input type="submit" name="add_user" value="Add User">
</form>
</div>
<div class="card">
<h2>User List</h2>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>ID</th>
<th>Email address</th>
<th>Auth Type</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr>
<td><?php echo $user['id']; ?></td>
<td><?php echo htmlspecialchars($user['username']); ?></td>
<td>
<?php echo $user['password_hash'] ? 'Password + SSO' : 'SSO Only'; ?>
</td>
<td>
<?php if ($user['id'] != $logged_in_user_id): ?>
<a href="manage_users.php?delete=<?php echo $user['id']; ?>" onclick="return confirm('Are you sure you want to delete this user?');" class="btn btn-danger" style="padding: 0.25rem 0.5rem; font-size: 0.875rem;">Delete</a>
<?php else: ?>
<em>Current User</em>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<p><a href="dashboard.php">Back to Dashboard</a></p>
</div>
</body>
</html>

141
public/setup.php Normal file
View File

@@ -0,0 +1,141 @@
<?php
error_reporting(0);
ini_set('display_errors', 0);
function handleFatalError()
{
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
die("A fatal error occurred. Please check your configuration and try again.");
}
}
register_shutdown_function('handleFatalError');
if (!file_exists('../dbconfig.php')) {
die("The database configuration file (dbconfig.php) is missing. Please create it with the correct database credentials.");
}
include '../dbconfig.php';
if ($link === null || $link->connect_error) {
die("Database connection failed. Please check the dbconfig.php file and ensure the database credentials are correct.");
}
$tables = [];
$result = $link->query("SHOW TABLES");
if ($result) {
while ($row = $result->fetch_row()) {
$tables[] = $row[0];
}
$result->free();
}
if (!empty($tables)) {
die("An installation already exists in this database. Please clean up the database or update the dbconfig.php file to use a new database.");
}
if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_POST['create_admin'])) {
$username = $_POST['username'];
$password = $_POST['password'];
if (empty($username) || empty($password)) {
echo "Username and password are required.";
} elseif (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
echo "Username must be a valid email address.";
} else {
$password_hash = password_hash($password, PASSWORD_DEFAULT);
$create_tables_sql = [
"CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NULL
)",
"CREATE TABLE aws_credentials (
id INT AUTO_INCREMENT PRIMARY KEY,
region VARCHAR(50) NOT NULL,
access_key_id VARCHAR(255) NOT NULL,
secret_access_key VARCHAR(255) NOT NULL,
hosted_zone_id VARCHAR(255) NOT NULL,
approved_fqdn VARCHAR(255) NOT NULL
)",
"CREATE TABLE ddns_entries (
id INT AUTO_INCREMENT PRIMARY KEY,
ddns_fqdn VARCHAR(255) NOT NULL UNIQUE,
ddns_password VARCHAR(255) NOT NULL,
last_ipv4 VARCHAR(15),
ttl INT NOT NULL DEFAULT 300,
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)",
"CREATE TABLE ddns_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
ddns_entry_id INT NOT NULL,
action VARCHAR(50) NOT NULL,
ip_address VARCHAR(15),
details TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ddns_entry_id) REFERENCES ddns_entries(id) ON DELETE CASCADE
)",
"CREATE TABLE recaptcha_keys (
id INT AUTO_INCREMENT PRIMARY KEY,
site_key VARCHAR(255) NOT NULL,
secret_key VARCHAR(255) NOT NULL
)",
"CREATE PROCEDURE CleanupOldLogs()
BEGIN
DELETE FROM ddns_logs WHERE timestamp < NOW() - INTERVAL 30 DAY;
END"
];
$success = true;
foreach ($create_tables_sql as $sql) {
if (!$link->query($sql)) {
$success = false;
echo "Error creating table or procedure: " . $link->error;
break;
}
}
if ($success) {
$insert_sql = "INSERT INTO users (username, password_hash) VALUES (?, ?)";
if ($stmt = $link->prepare($insert_sql)) {
$stmt->bind_param("ss", $username, $password_hash);
if ($stmt->execute()) {
echo "First admin user created successfully! You can now log in.";
echo '<p><a href="index.php">Go to Login Page</a></p>';
exit;
} else {
echo "Error creating admin user: " . $stmt->error;
}
$stmt->close();
}
}
}
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Setup</title>
<title>Setup</title>
</head>
<body>
<h1>Setup</h1>
<p>Welcome to the setup wizard. This script will help you prepare a new installation.</p>
<?php if (empty($tables)): ?>
<h2>Create First Admin User</h2>
<form method="post">
<label>Email address:</label>
<input type="email" name="username" required><br>
<label>Password:</label>
<input type="password" name="password" required><br>
<input type="submit" name="create_admin" value="Create Admin User">
</form>
<?php endif; ?>
</body>
</html>

244
public/style.css Normal file
View File

@@ -0,0 +1,244 @@
/* Modern Dark Theme with Glassmorphism */
:root {
--bg-color: #0f172a;
--text-color: #e2e8f0;
--primary-color: #6366f1;
--primary-hover: #4f46e5;
--secondary-color: #10b981;
--surface-color: rgba(30, 41, 59, 0.7);
--border-color: rgba(148, 163, 184, 0.1);
--error-color: #ef4444;
--glass-bg: rgba(30, 41, 59, 0.6);
--glass-border: rgba(255, 255, 255, 0.1);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 0;
line-height: 1.6;
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(16, 185, 129, 0.15) 0px, transparent 50%);
background-attachment: fixed;
min-height: 100vh;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: #f8fafc;
margin-bottom: 1rem;
font-weight: 600;
}
a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
text-decoration: underline;
}
/* Layout */
.container {
margin: 0 auto;
padding: 2rem;
}
/* Cards / Glassmorphism */
.card {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 1rem;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow);
}
/* Forms */
form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 500px;
}
label {
font-weight: 500;
color: #cbd5e1;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
select,
textarea {
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.75rem;
color: var(--text-color);
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
/* Buttons */
button,
input[type="submit"],
.btn {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 0.5rem;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
display: inline-block;
text-align: center;
text-decoration: none;
}
button:hover,
input[type="submit"]:hover,
.btn:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
text-decoration: none;
}
button:active,
input[type="submit"]:active,
.btn:active {
transform: translateY(0);
}
.btn-danger {
background-color: var(--error-color);
}
.btn-danger:hover {
background-color: #dc2626;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
background: rgba(15, 23, 42, 0.3);
border-radius: 0.5rem;
overflow: hidden;
}
th,
td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: rgba(30, 41, 59, 0.8);
font-weight: 600;
color: #f8fafc;
}
tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
/* Alerts */
.alert {
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid transparent;
}
.alert-error {
background-color: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.alert-success {
background-color: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
}
/* Login Page Specific */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.mt-4 {
margin-top: 1rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.flex {
display: flex;
}
.gap-2 {
gap: 0.5rem;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.flex {
flex-wrap: wrap;
}
/* Stack buttons on very small screens if needed, or keep wrapped */
.flex > .btn {
flex: 1 1 auto; /* Allow buttons to grow and fill width */
text-align: center;
}
form {
max-width: 100%;
}
/* Table Wrapper class to be added in PHP */
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Ensure table doesn't force width */
table {
min-width: 600px; /* Force scroll if content is squished */
}
}

48
public/table_sort.js Normal file
View File

@@ -0,0 +1,48 @@
document.addEventListener('DOMContentLoaded', function() {
const table = document.getElementById('ddnsTable');
if (!table) return;
const headers = table.querySelectorAll('th.sortable');
const tbody = table.querySelector('tbody');
headers.forEach((header, index) => {
header.addEventListener('click', () => {
const type = header.dataset.type || 'string';
const isAscending = header.classList.contains('asc');
// Reset all headers
headers.forEach(h => h.classList.remove('asc', 'desc'));
// Toggle sort order
if (!isAscending) {
header.classList.add('asc');
} else {
header.classList.add('desc');
}
const rows = Array.from(tbody.querySelectorAll('tr'));
const sortedRows = rows.sort((a, b) => {
// Find the cell corresponding to the header index
// Note: We need to account for the fact that headers might not match columns 1:1 if there are colspans,
// but here it seems straightforward. However, we should find the index of the header among all ths in the thead
// to match the td index.
const allHeaders = Array.from(header.parentElement.children);
const colIndex = allHeaders.indexOf(header);
const aText = a.children[colIndex].textContent.trim();
const bText = b.children[colIndex].textContent.trim();
if (type === 'number') {
return isAscending ? bText - aText : aText - bText;
} else {
return isAscending ? bText.localeCompare(aText) : aText.localeCompare(bText);
}
});
// Re-append rows
tbody.innerHTML = '';
sortedRows.forEach(row => tbody.appendChild(row));
});
});
});

120
public/update.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
include '../dbconfig.php';
require '../vendor/aws.phar';
use Aws\Route53\Route53Client;
use Aws\Exception\AwsException;
// Clean up logs older than 30 days
$cleanup_sql = "CALL CleanupOldLogs()";
if ($cleanup_stmt = $link->prepare($cleanup_sql)) {
$cleanup_stmt->execute();
$cleanup_stmt->close();
}
// Extract the hostname and IP from the request
$ddns_fqdn = $_GET['hostname'];
$myip = $_GET['myip'];
$ddns_password = $_SERVER['PHP_AUTH_PW'];
// Validate the request
if (empty($ddns_fqdn) || empty($myip) || empty($ddns_password)) {
die("badauth");
}
// Fetch the DDNS entry from the database
$sql = "SELECT id, ddns_fqdn, ddns_password, last_ipv4, ttl FROM ddns_entries WHERE ddns_fqdn = ? AND ddns_password = ?";
if ($stmt = $link->prepare($sql)) {
$stmt->bind_param("ss", $ddns_fqdn, $ddns_password);
$stmt->execute();
$stmt->store_result();
if ($stmt->num_rows == 1) {
$stmt->bind_result($id, $ddns_fqdn, $ddns_password, $last_ipv4, $ttl);
$stmt->fetch();
// Check if the IP has changed
if ($last_ipv4 !== $myip) {
// Fetch AWS credentials from the database
$aws_sql = "SELECT region, access_key_id, secret_access_key, hosted_zone_id FROM aws_credentials LIMIT 1";
if ($aws_stmt = $link->prepare($aws_sql)) {
$aws_stmt->execute();
$aws_stmt->store_result();
$aws_stmt->bind_result($region, $access_key_id, $secret_access_key, $hosted_zone_id);
$aws_stmt->fetch();
$aws_stmt->close();
// Initialize the Route53 client
$route53 = new Route53Client([
'version' => 'latest',
'region' => $region,
'credentials' => [
'key' => $access_key_id,
'secret' => $secret_access_key,
],
]);
// Prepare the DNS record update
$changeBatch = [
'Changes' => [
[
'Action' => 'UPSERT',
'ResourceRecordSet' => [
'Name' => $ddns_fqdn,
'Type' => 'A',
'TTL' => $ttl,
'ResourceRecords' => [
[
'Value' => $myip,
],
],
],
],
],
];
try {
// Update the DNS record in Route53
$result = $route53->changeResourceRecordSets([
'HostedZoneId' => $hosted_zone_id,
'ChangeBatch' => $changeBatch,
]);
// Update the database with the new IP
$update_sql = "UPDATE ddns_entries SET last_ipv4 = ?, last_update = NOW() WHERE id = ?";
if ($update_stmt = $link->prepare($update_sql)) {
$update_stmt->bind_param("si", $myip, $id);
$update_stmt->execute();
$update_stmt->close();
}
// Log the action
$action = 'update';
$ip_address = $_SERVER['REMOTE_ADDR'];
$details = "Updated IP: $myip";
$log_sql = "INSERT INTO ddns_logs (ddns_entry_id, action, ip_address, details) VALUES (?, ?, ?, ?)";
if ($log_stmt = $link->prepare($log_sql)) {
$log_stmt->bind_param("isss", $id, $action, $ip_address, $details);
$log_stmt->execute();
$log_stmt->close();
}
echo "good"; // Success
} catch (AwsException $e) {
echo "dnserror"; // DNS update failed
}
} else {
echo "badauth"; // AWS credentials not found
}
} else {
echo "nochg"; // IP hasn't changed
}
} else {
echo "badauth"; // Invalid DDNS credentials
}
$stmt->close();
} else {
echo "badauth"; // Database error
}
$link->close();
?>

141
public/view_logs.php Normal file
View File

@@ -0,0 +1,141 @@
<?php
session_start();
if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
header('location: index.php');
exit;
}
include '../dbconfig.php';
// Clean up logs older than 30 days
$cleanup_sql = "CALL CleanupOldLogs()";
if ($cleanup_stmt = $link->prepare($cleanup_sql)) {
$cleanup_stmt->execute();
$cleanup_stmt->close();
}
// Initialize variables
$ddns_id = isset($_GET['ddns_id']) ? (int) $_GET['ddns_id'] : null;
$where_clause = "";
$params = [];
$types = "";
// Build WHERE clause if ddns_id is specified
if ($ddns_id !== null) {
$where_clause = " WHERE l.ddns_entry_id = ?";
$params[] = $ddns_id;
$types = "i";
}
// Pagination setup
$per_page = 20;
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
$offset = ($page - 1) * $per_page;
// Main query with conditional filtering
$query = "SELECT l.*, d.ddns_fqdn
FROM ddns_logs l
LEFT JOIN ddns_entries d ON l.ddns_entry_id = d.id
$where_clause
ORDER BY l.timestamp DESC
LIMIT ?, ?";
// Count query with same filtering
$count_query = "SELECT COUNT(*) as total
FROM ddns_logs l
$where_clause";
// Prepare and execute count query
$count_stmt = $link->prepare($count_query);
if ($ddns_id !== null) {
$count_stmt->bind_param($types, ...$params);
}
$count_stmt->execute();
$total = $count_stmt->get_result()->fetch_assoc()['total'];
$count_stmt->close();
// Calculate total pages
$pages = ceil($total / $per_page);
// Prepare main query
$stmt = $link->prepare($query);
if ($ddns_id !== null) {
$params[] = $offset;
$params[] = $per_page;
$stmt->bind_param($types . "ii", ...$params);
} else {
$stmt->bind_param("ii", $offset, $per_page);
}
$stmt->execute();
$logs = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $ddns_id ? "Logs for DDNS #$ddns_id" : "All DDNS Logs" ?></title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1><?= $ddns_id ? "Logs for DDNS Entry #$ddns_id" : "All DDNS Logs" ?></h1>
<div class="card">
<!-- Logs Table -->
<div class="table-responsive">
<table>
<thead>
<tr>
<th>FQDN</th>
<th>Action</th>
<th>IP</th>
<th>Details</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $log): ?>
<tr>
<td><?= htmlspecialchars($log['ddns_fqdn'] ?? 'N/A') ?></td>
<td><?= htmlspecialchars($log['action']) ?></td>
<td><?= htmlspecialchars($log['ip_address']) ?></td>
<td><?= htmlspecialchars($log['details']) ?></td>
<td><?= htmlspecialchars($log['timestamp']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex gap-2 mt-4" style="justify-content: center;">
<?php if ($page > 1): ?>
<a href="?<?= http_build_query(array_merge($_GET, ['page' => $page - 1])) ?>" class="btn">Previous</a>
<?php endif; ?>
<?php for ($i = 1; $i <= $pages; $i++): ?>
<a href="?<?= http_build_query(array_merge($_GET, ['page' => $i])) ?>"
class="btn <?= $i == $page ? 'btn-primary' : '' ?>"
style="<?= $i == $page ? 'background-color: var(--primary-hover);' : '' ?>"><?= $i ?></a>
<?php endfor; ?>
<?php if ($page < $pages): ?>
<a href="?<?= http_build_query(array_merge($_GET, ['page' => $page + 1])) ?>" class="btn">Next</a>
<?php endif; ?>
</div>
</div>
<p class="mt-4">
<a href="<?= $ddns_id ? 'manage_ddns.php' : 'dashboard.php' ?>">
Back to <?= $ddns_id ? 'DDNS Management' : 'Dashboard' ?>
</a>
</p>
</div>
</body>
</html>

BIN
vendor/aws.phar vendored Normal file

Binary file not shown.