diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2eac372 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git/ +.github/ +docker/ +.dockerignore +CNAME +Dockerfile +README.md +LICENSE diff --git a/.github/workflows/release_build.yml b/.github/workflows/release_build.yml new file mode 100644 index 0000000..fb79cd0 --- /dev/null +++ b/.github/workflows/release_build.yml @@ -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<> "$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<> "$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<> "$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 diff --git a/.github/workflows/sync_repo.yml b/.github/workflows/sync_repo.yml new file mode 100644 index 0000000..5d56799 --- /dev/null +++ b/.github/workflows/sync_repo.yml @@ -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 diff --git a/.github/workflows/update_readme.yml b/.github/workflows/update_readme.yml new file mode 100644 index 0000000..b635451 --- /dev/null +++ b/.github/workflows/update_readme.yml @@ -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 '//{flag=1;next}//{flag=0}flag' source_readme/README.md) + BUTTONS_UPDATED=$(echo "$BUTTONS" | sed "s/\.github/${REPO_NAME}/g") + + # --- Extract footer block from source (everything from onward) --- + FOOTER=$(awk '//{flag=1}flag' source_readme/README.md) + + # --- Replace buttons section in README.md --- + UPDATED=$(awk -v buttons="$BUTTONS_UPDATED" ' + BEGIN { skip=0 } + // { + print + print buttons + skip=1 + next + } + // && skip { + print + skip=0 + next + } + !skip { print } + ' README.md) + + # --- Replace everything after with FOOTER --- + echo "$UPDATED" | awk -v 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 }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..60676f3 --- /dev/null +++ b/Dockerfile @@ -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;' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3c1c4d9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 82ac209..08f13ce 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,123 @@ -# ddnsonroute53 +# DDNS on Route53 +Router friendly client to update AWS Route53 entries for Dynamic DNS funtionality + +[![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] + + +## 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 +``` + + +--- + +## 🧑‍💻 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 diff --git a/dbconfig.php b/dbconfig.php new file mode 100644 index 0000000..10a9f58 --- /dev/null +++ b/dbconfig.php @@ -0,0 +1,23 @@ +connect_error) { + error_log("Database connection failed: " . $link->connect_error); +} +?> \ No newline at end of file diff --git a/docker/.env b/docker/.env new file mode 100644 index 0000000..805b4e4 --- /dev/null +++ b/docker/.env @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..468fe9b --- /dev/null +++ b/docker/docker-compose.yml @@ -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 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..d4c252b --- /dev/null +++ b/manifest.json @@ -0,0 +1,4 @@ +{ + "version": "3.0.5", + "author": "Ivan Carlos" +} diff --git a/public/dashboard.php b/public/dashboard.php new file mode 100644 index 0000000..af0a7d3 --- /dev/null +++ b/public/dashboard.php @@ -0,0 +1,42 @@ + + + + + + + Dashboard + + + +
+ + +
+

DDNS Update cURL Command

+

Use the following cURL command to update your DDNS entry:

+
+
+
+ + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..78443e5 --- /dev/null +++ b/public/index.php @@ -0,0 +1,145 @@ +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"; + +?> + + + + + + + Login - DDNS Manager + + + + + + + + \ No newline at end of file diff --git a/public/manage_aws.php b/public/manage_aws.php new file mode 100644 index 0000000..1e63173 --- /dev/null +++ b/public/manage_aws.php @@ -0,0 +1,126 @@ +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(); +?> + + + + + + + Manage AWS Credentials + + + + +
+

Manage AWS Credentials

+ + +
+ + +
+ + +
+ +

Current AWS Credentials

+
    +
  • Region:
  • +
  • Access Key ID: +
  • +
  • Secret Access Key: +
  • +
  • Hosted Zone ID: +
  • +
  • Approved FQDN: +
  • +
+ +

No AWS credentials found in the database.

+ +
+ +
+

Update AWS Credentials

+
+ + + + + + + + + + + + + + + + +
+
+ +

Back to Dashboard

+
+ + + \ No newline at end of file diff --git a/public/manage_ddns.php b/public/manage_ddns.php new file mode 100644 index 0000000..d9c6097 --- /dev/null +++ b/public/manage_ddns.php @@ -0,0 +1,397 @@ +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(); +} +?> + + + + + + Manage DDNS Entries + + + + + +
+

Manage DDNS Entries

+ + +
+ + +
+ + +
+

Add New DDNS Entry

+
+ + + + + + + + + + + + + +
+
+ +
+

DDNS Entries

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
FQDNPasswordLast IPv4TTLLast UpdateUpdate IP/TTLLogsAction
+
+ +
+ + + +
+
+
+ Logs + + Delete +
+
+
+ +

Back to Dashboard

+
+ + \ No newline at end of file diff --git a/public/manage_users.php b/public/manage_users.php new file mode 100644 index 0000000..2a6cc8f --- /dev/null +++ b/public/manage_users.php @@ -0,0 +1,158 @@ +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(); +} +?> + + + + + + Manage Users + + + +
+

Manage Users

+ + +
+ + +
+ + +
+

Add New User

+

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

+
+ + + + + + + +
+
+ +
+

User List

+
+ + + + + + + + + + + + + + + + + + + +
IDEmail addressAuth TypeAction
+ + + + Delete + + Current User + +
+
+
+ +

Back to Dashboard

+
+ + \ No newline at end of file diff --git a/public/setup.php b/public/setup.php new file mode 100644 index 0000000..061edcf --- /dev/null +++ b/public/setup.php @@ -0,0 +1,141 @@ +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 '

Go to Login Page

'; + exit; + } else { + echo "Error creating admin user: " . $stmt->error; + } + $stmt->close(); + } + } + } +} +?> + + + + + Setup + Setup + + + +

Setup

+

Welcome to the setup wizard. This script will help you prepare a new installation.

+ + +

Create First Admin User

+
+ +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..1477c31 --- /dev/null +++ b/public/style.css @@ -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 */ + } +} diff --git a/public/table_sort.js b/public/table_sort.js new file mode 100644 index 0000000..6226a25 --- /dev/null +++ b/public/table_sort.js @@ -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)); + }); + }); +}); diff --git a/public/update.php b/public/update.php new file mode 100644 index 0000000..8a523ea --- /dev/null +++ b/public/update.php @@ -0,0 +1,120 @@ +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(); +?> diff --git a/public/view_logs.php b/public/view_logs.php new file mode 100644 index 0000000..8d4cf48 --- /dev/null +++ b/public/view_logs.php @@ -0,0 +1,141 @@ +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(); +?> + + + + + + + + <?= $ddns_id ? "Logs for DDNS #$ddns_id" : "All DDNS Logs" ?> + + + + +
+

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
FQDNActionIPDetailsTimestamp
+
+ + +
+ 1): ?> + Previous + + + + + + + + Next + +
+
+ +

+ + Back to + +

+
+ + + \ No newline at end of file diff --git a/vendor/aws.phar b/vendor/aws.phar new file mode 100644 index 0000000..9a14b43 Binary files /dev/null and b/vendor/aws.phar differ