Compare commits

...
91 Commits
Author SHA1 Message Date
github-actions[bot] b398b5498c Update Tailscale to v1.98.3 2026-05-22 04:30:26 +00:00
github-actions[bot] 08a2140d68 Update Tailscale to v1.98.2 2026-05-19 04:28:36 +00:00
Weston Blieden 1a5d2c1a24 docs: update README with Accept DNS and Accept Routes settings 2026-05-12 14:11:18 +02:00
Weston Blieden 4066273b3f Add accept-routes toggle to roadmap 2026-05-05 09:53:10 +02:00
Weston Blieden 924f04c44a Add DNS toggle and tiny-tailscale to roadmap 2026-05-05 08:32:08 +02:00
Weston Blieden 674f1c4853 Add Roadmap section with AXIS OS 13 preparation items 2026-05-05 08:00:09 +02:00
Weston BliedenandGitHub 93855e5762 Update README.md to remove oosmetrics badge
Removed outdated oosmetrics badge and adjusted spacing.
2026-05-02 21:07:29 +02:00
Weston BliedenandGitHub 889db273ec Update README with additional badges 2026-05-02 14:35:40 +02:00
Weston Blieden 52572fc61c Update homepage: remove custom variant, add ACAP3 card, rotating hero word, device language 2026-04-21 08:57:56 +02:00
Weston BliedenandGitHub 76138394e1 Update Buy Me A Coffee badge in README 2026-04-19 14:59:22 +02:00
Weston BliedenandGitHub db24028d61 Simplify description of Headscale compatibility 2026-04-17 22:01:28 +02:00
Weston Blieden f5a30122a3 Fix password manager prompt: change auth key input from type=password to type=text 2026-04-17 19:56:04 +02:00
Weston Blieden 768a859c1f Fix password manager prompt on auth key input: use autocomplete=new-password 2026-04-17 19:51:12 +02:00
Weston Blieden f395d91de8 Fix auto-focus on settings inputs: add autocomplete=off 2026-04-17 19:42:25 +02:00
Weston Blieden 41e2a508c0 v1.96.4-r3 - Remove icon from header, change accent color to #2e2d2d 2026-04-17 19:25:04 +02:00
Weston Blieden 256aaa9d23 Fix param_bridge.c build errors: move g_ax_handle declaration before watchdog_cb, add missing TRUE arg to ax_parameter_set 2026-04-17 18:31:19 +02:00
Mo3heandClaude Sonnet 4.6 3cc3cd4804 Add Buy Me a Coffee link to homepage nav and footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:26:08 +02:00
Weston BliedenandGitHub 2c7ce1dc52 Add 'Buy Me A Coffee' badge to README
Added a 'Buy Me A Coffee' badge to the README.
2026-04-17 16:20:58 +02:00
Weston BliedenandGitHub 6bacc9e92b Remove BuyMeACoffee entry from FUNDING.yml
Removed BuyMeACoffee funding option.
2026-04-17 16:20:44 +02:00
Weston BliedenandGitHub 281fed0aa0 Fix Buy Me a Coffee username case
Updated Buy Me a Coffee username to match GitHub format.
2026-04-17 16:18:51 +02:00
Weston BliedenandGitHub 988adfc83a Change Buy Me a Coffee username to lowercase
Updated Buy Me a Coffee username to lowercase.
2026-04-17 16:16:19 +02:00
Weston BliedenandGitHub 0761b6e959 Add Buy Me a Coffee funding option 2026-04-17 16:12:37 +02:00
Weston Blieden ab2f4c050e docs: ROOT variants only supported on 11.11-11.x, OS 12 removed root access 2026-04-17 14:29:25 +02:00
Weston Blieden c396f00d0b docs: fix min Axis OS to 11.11+ for all variants including ROOT 2026-04-17 14:28:21 +02:00
Weston Blieden ced340453c v1.96.4-r2: C param bridge, configurable proxy ports, SDK 1.15.1, custom variants folded in, UI fixes 2026-04-17 14:05:09 +02:00
Weston BliedenandGitHub 4afd79167e Remove redundant phrase in disclaimer section 2026-04-15 10:00:55 +02:00
Weston BliedenandGitHub e9919e069d Refine disclaimer wording in README.md
Updated disclaimer to remove redundant wording.
2026-04-15 09:56:24 +02:00
Weston Blieden 732dacc5ba ci: add acap3 variant support to build workflow 2026-04-15 08:36:03 +02:00
Weston Blieden 27e26c65e3 docs: remove emoji from README 2026-04-15 08:35:19 +02:00
Weston Blieden 8a070bf5bf docs: add arm_acap3 legacy variant to README compatibility table and feature list 2026-04-14 21:07:22 +02:00
Weston Blieden de58375eb3 chore(acap3): add remaining app source files (Makefile, launcher.c, package.conf) 2026-04-14 21:04:24 +02:00
Weston Blieden 86c6ecb385 fix(acap3): fetch tailscaled.log for status details, fix connecting→connected for all variants
- arm_acap3 start.sh: log IP, version and auth URL to syslog via 'tailscale ip/version'
- arm_acap3 index.html: fetch tailscaled.log (symlinked into html/) in addition to
  syslog so IP, version, tailnet and -> Running state are always available
- arm_acap3 Dockerfile: bake html/tailscaled.log symlink into .eap
- all variants index.html: upgrade 'connecting' (without auth URL) to 'connected'
  when VAPIX list.cgi confirms Status=Running (was only upgrading from 'disconnected')
2026-04-14 21:04:16 +02:00
Weston BliedenandGitHub 7bb541fa2a Format disclaimer section in README.md 2026-04-14 19:18:24 +02:00
Weston Blieden 3e87803625 fix: always check app status API so UI shows Stopped when ACAP is not running 2026-04-14 15:43:18 +02:00
Weston Blieden 8bd18ffc63 fix: use syslog header as primary node name source (prevents stale acap-tailscale_vpn) 2026-04-14 15:36:13 +02:00
Weston Blieden c2b15fb7ec fix: pass --hostname=$(hostname) to tailscale up so node name matches camera hostname 2026-04-14 15:29:20 +02:00
Weston Blieden b07e4f1f61 fix: use cross-strip in Dockerfiles to reduce .eap size (32M -> 16M) 2026-04-14 15:22:11 +02:00
Weston Blieden 75f9cfd4b7 fix: show auth URL even when stale Running entry exists in syslog (reinstall/re-login) 2026-04-14 15:12:19 +02:00
Weston Blieden 96e8a8fd97 fix: correct flag name --outbound-http-proxy-listen (remove trailing 'er') 2026-04-14 15:02:06 +02:00
Weston Blieden 8c9072b876 Fix README: proxy available on all non-ROOT variants, not just custom 2026-04-14 13:53:03 +02:00
Weston Blieden cf84c3cdb6 Add HTTP CONNECT proxy (8080) to standard aarch64 and arm variants 2026-04-14 13:49:24 +02:00
Weston Blieden db05725432 Add HTTP CONNECT proxy (8080) and SOCKS5 (1055) to custom variants 2026-04-14 13:44:31 +02:00
Weston Blieden ef7d73ca0f Remove --verbose=1 from tailscaled to reduce syslog noise 2026-04-14 08:13:51 +02:00
Mo3heandClaude Sonnet 4.6 5080ec39fb Fix BSD 3-Clause license compliance across all variants
- Set vendor to "Mo3he" and vendorUrl to GitHub repo in all manifest.json
  files to avoid implying Tailscale Inc. endorsement (Clause 3)
- Add Tailscale third-party notice to README.md
- Add attribution/disclaimer footer to docs/index.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:19:20 +02:00
Weston BliedenandGitHub 219ff70c84 Update disclaimer text in index.html 2026-04-13 16:59:13 +02:00
Weston BliedenandGitHub 7a7ecec47c Update README.md 2026-04-13 16:57:22 +02:00
Weston Blieden 3ab196b7b5 docs: add disclaimer to homepage hero 2026-04-13 16:34:16 +02:00
Weston BliedenandGitHub 6be4d7d8ad Format disclaimer section in README 2026-04-13 16:25:46 +02:00
Weston BliedenandGitHub 64ee3e49ab Add disclaimer to README
Added a disclaimer about the project's independence from Axis Communications.
2026-04-13 16:22:51 +02:00
Weston Blieden 4df48e8ae6 fix: node name, account, version and IP persist after syslog rotation 2026-04-13 14:33:16 +02:00
Weston Blieden 806c3309b6 CI: strip debug info from Tailscale binaries to reduce .eap size ~30% 2026-04-11 21:17:55 +02:00
Weston Blieden a4deaeacae Fix empty syslog: pipe tailscaled output via --verbose=1 to logger, add app list API fallback to all variants 2026-04-11 20:59:35 +02:00
Weston Blieden 6ba1c1a1a1 Fix UI: cache connection details in localStorage to survive syslog rotation, detect running state from health/derp/c2n patterns 2026-04-11 18:04:22 +02:00
Weston BliedenandGitHub 25df94e489 Fix README formatting and remove duplicate link
Removed duplicate homepage link and updated formatting.
2026-04-11 12:45:05 +02:00
Weston Blieden a72987391c Update Tailscale binaries to v1.96.4, fix UI parsing (last-match), add Check for Updates button, direct download links on homepage 2026-04-11 12:41:40 +02:00
Weston Blieden 04907d55bf Redesign ACAP UI: dark/light theme, version check, better log viewer 2026-04-11 09:39:17 +02:00
Weston Blieden 54a1859f14 Fix mobile layout for nav, hero, downloads, and footer 2026-04-11 07:05:55 +02:00
Weston Blieden 81e38c28b6 Add sponsor link to nav bar 2026-04-11 06:28:45 +02:00
Weston Blieden 8643e99833 Add Open Graph meta tags and social sharing image 2026-04-11 05:43:41 +02:00
Weston Blieden 95e4a9aec2 Add homepage link to README 2026-04-11 05:40:27 +02:00
Weston Blieden fb67b6dff3 Add GitHub Pages homepage with light/dark theme 2026-04-11 05:37:53 +02:00
Weston BliedenandGitHub 77fc186d0f Update sponsorship wording in README 2026-04-05 20:53:18 +02:00
Weston BliedenandGitHub b789d2e78e Update README to improve wording 2026-04-05 20:51:09 +02:00
Weston BliedenandGitHub 937b8ede3e Update project title in README.md 2026-04-05 20:49:44 +02:00
Weston BliedenandGitHub 49898008f6 Update installation instructions for clarity 2026-04-05 20:48:39 +02:00
github-actions[bot] 5b09a40231 Update Tailscale to v1.96.4 2026-03-28 02:52:43 +00:00
Weston BliedenandGitHub 98918ca6f5 Update README.md 2026-03-26 10:18:16 +01:00
Weston Blieden 08b0fdc081 Create LICENSE 2026-03-19 16:39:22 +01:00
Weston Blieden f3c6dd54f3 Update build.yml 2026-03-19 16:26:26 +01:00
Weston Blieden c3cbf8d433 Improve all variants: new UI, respawn mode, arm_custom, binary gitignore 2026-03-19 16:23:44 +01:00
github-actions[bot] 39a5a42def Update Tailscale to v1.96.2 2026-03-19 02:55:58 +00:00
github-actions[bot] 713550d1c0 Update Tailscale to v1.94.2 2026-02-26 02:46:36 +00:00
github-actions[bot] 14db783425 Update Tailscale to v1.94.1 2026-01-28 02:26:05 +00:00
github-actions[bot] c02f0aa498 Update Tailscale to v1.92.5 2026-01-07 02:22:01 +00:00
github-actions[bot] 96477cdb0d Update Tailscale to v1.92.3 2025-12-17 08:38:44 +00:00
Weston BliedenandGitHub 4e0eabeec3 Update version in manifest.json to 1.92.1 2025-12-17 09:36:08 +01:00
Weston BliedenandGitHub 59161ccd91 Downgrade version from 1.92.3 to 1.92.1 2025-12-17 09:35:26 +01:00
Weston BliedenandGitHub e1b2ac3490 Downgrade version from 1.92.3 to 1.92.1 2025-12-17 09:35:04 +01:00
Weston BliedenandGitHub 5f03ac918f Update version in manifest.json to 1.92.1 2025-12-17 09:34:45 +01:00
Weston BliedenandGitHub 2db9f27181 Update version in manifest.json to 1.92.1 2025-12-17 09:34:26 +01:00
Weston Blieden 396b450415 Store state in localdata
The tailscaled.state file in localdata will now persist across updates, so your camera stays connected to Tailscale without re-authentication
2025-12-17 09:31:34 +01:00
github-actions[bot] 2d1ff5e8a6 Update Tailscale to v1.92.3 2025-12-17 07:36:38 +00:00
Weston BliedenandGitHub 7655028477 Update cron schedule for build workflow 2025-12-17 08:33:36 +01:00
Mo3he f890d8df1a Re add lib folders after housekeeping 2025-12-15 15:48:21 +01:00
Weston BliedenandGitHub df0756a64d Update .gitignore to exclude release and Tailscale files
Added entries to ignore release artifacts and Tailscale files.
2025-12-15 12:25:06 +01:00
Weston BliedenandGitHub df2cf84c6c Modify build.yml to limit git add to manifest files
Only commit manifest files and ignore release artifacts.
2025-12-15 12:23:57 +01:00
github-actions[bot] f420e7d24c Update Tailscale to v1.92.1 2025-12-15 11:02:54 +00:00
Weston BliedenandGitHub 3dc49f1254 Enhance Tailscale version retrieval logic
Updated the workflow to check for ARM build availability and fallback to the latest version if not found.
2025-12-15 11:58:09 +01:00
github-actions[bot] 2005ba32db Update Tailscale to v1.90.9 2025-11-26 15:08:35 +00:00
github-actions[bot] 1f9c6a9e45 Update Tailscale to v1.90.8 2025-11-20 14:26:56 +00:00
github-actions[bot] 6148fc3298 Update Tailscale to v1.90.6 2025-11-03 03:52:08 +00:00
60 changed files with 6404 additions and 590 deletions
+63 -17
View File
@@ -2,7 +2,7 @@ name: Auto Build & Release Tailscale ACAP
on: on:
schedule: schedule:
- cron: "0 3 * * 1" # Every Monday at 03:00 UTC - cron: "0 0 * * *" # Every Monday at 03:00 UTC
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -20,8 +20,28 @@ jobs:
- name: Get latest Tailscale version - name: Get latest Tailscale version
id: tailscale_version id: tailscale_version
run: | run: |
VERSION=$(curl -s https://api.github.com/repos/tailscale/tailscale/releases/latest | jq -r .tag_name) # 1. Start from GitHub latest
VERSION=${VERSION#v} # remove leading 'v' GH_TAG=$(curl -s https://api.github.com/repos/tailscale/tailscale/releases/latest | jq -r .tag_name)
GH_VERSION=${GH_TAG#v}
echo "GitHub latest: $GH_VERSION"
# 2. See if static ARM build exists for that version
if curl -sfI "https://pkgs.tailscale.com/stable/tailscale_${GH_VERSION}_arm.tgz" > /dev/null; then
VERSION="$GH_VERSION"
echo "Using GitHub latest (has ARM package): $VERSION"
else
echo "No ARM package for $GH_VERSION, falling back to latest version on pkgs.tailscale.com"
# 3. Derive latest version that actually has an ARM tarball
VERSION=$(
curl -s https://pkgs.tailscale.com/stable/ \
| grep -o 'tailscale_[0-9.]*_arm\.tgz' \
| sed -E 's/^tailscale_([0-9.]+)_arm\.tgz$/\1/' \
| sort -V | tail -n1
)
echo "Fallback version: $VERSION"
fi
echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
@@ -37,8 +57,12 @@ jobs:
run: | run: |
echo "Repo version: $CURRENT_VERSION" echo "Repo version: $CURRENT_VERSION"
echo "Latest Tailscale version: $RELEASE_VERSION" echo "Latest Tailscale version: $RELEASE_VERSION"
echo "Trigger: ${{ github.event_name }}"
if [ "$CURRENT_VERSION" = "$RELEASE_VERSION" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "build_needed=true" >> $GITHUB_ENV
echo "Manual trigger — building regardless of version."
elif [ "$CURRENT_VERSION" = "$RELEASE_VERSION" ]; then
echo "build_needed=false" >> $GITHUB_ENV echo "build_needed=false" >> $GITHUB_ENV
echo "Already up to date. Skipping build." echo "Already up to date. Skipping build."
else else
@@ -61,7 +85,23 @@ jobs:
mv tailscale_bins/tailscale tailscale_bins/tailscale_arm64 mv tailscale_bins/tailscale tailscale_bins/tailscale_arm64
mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm64 mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm64
# 4. Build each folder, update manifest, and copy .eap files # 4. Strip binaries to reduce package size
- name: Strip binaries
if: env.build_needed == 'true'
run: |
# Install cross-architecture strip tools
sudo apt-get update
sudo apt-get install -y binutils-aarch64-linux-gnu binutils-arm-linux-gnueabihf
# Strip debug info and symbol tables (zero runtime/memory cost)
aarch64-linux-gnu-strip -s tailscale_bins/tailscale_arm64
aarch64-linux-gnu-strip -s tailscale_bins/tailscaled_arm64
arm-linux-gnueabihf-strip -s tailscale_bins/tailscale_arm
arm-linux-gnueabihf-strip -s tailscale_bins/tailscaled_arm
ls -lh tailscale_bins/
# 5. Build each folder, update manifest, and copy .eap files
- name: Build all folders - name: Build all folders
if: env.build_needed == 'true' if: env.build_needed == 'true'
run: | run: |
@@ -83,20 +123,24 @@ jobs:
cp tailscale_bins/tailscaled_arm64 "$folder/app/lib/tailscaled" cp tailscale_bins/tailscaled_arm64 "$folder/app/lib/tailscaled"
fi fi
# Stage updated binaries for commit
git add "$folder/app/lib/tailscale" "$folder/app/lib/tailscaled"
# Detect variant suffix for .eap naming # Detect variant suffix for .eap naming
if [[ "$FOLDER_NAME" == *_ROOT ]]; then if [[ "$FOLDER_NAME" == *_ROOT ]]; then
VARIANT="_root" VARIANT="_root"
elif [[ "$FOLDER_NAME" == *_custom ]]; then elif [[ "$FOLDER_NAME" == *_acap3 ]]; then
VARIANT="_custom" VARIANT="_acap3"
else else
VARIANT="" VARIANT=""
fi fi
# Update manifest version # Update version — manifest.json for ACAP 4, package.conf for ACAP 3
sed -i "s/\"version\": \".*\"/\"version\": \"${RELEASE_VERSION}\"/" "$folder/app/manifest.json" if [[ -f "$folder/app/manifest.json" ]]; then
sed -i "s/\"version\": \".*\"/\"version\": \"${RELEASE_VERSION}\"/" "$folder/app/manifest.json"
elif [[ -f "$folder/app/package.conf" ]]; then
IFS='.' read -r MAJOR MINOR MICRO <<< "${RELEASE_VERSION}"
sed -i "s/^APPMAJORVERSION=.*/APPMAJORVERSION=${MAJOR}/" "$folder/app/package.conf"
sed -i "s/^APPMINORVERSION=.*/APPMINORVERSION=${MINOR}/" "$folder/app/package.conf"
sed -i "s/^APPMICROVERSION=.*/APPMICROVERSION=${MICRO}/" "$folder/app/package.conf"
fi
# Docker build # Docker build
TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean
@@ -106,7 +150,9 @@ jobs:
# Extract .eap files into build folder # Extract .eap files into build folder
EAP_OUTPUT="./build/${TAG_NAME}" EAP_OUTPUT="./build/${TAG_NAME}"
mkdir -p "$EAP_OUTPUT" mkdir -p "$EAP_OUTPUT"
docker cp $(docker create "$TAG_NAME"):/opt/app "$EAP_OUTPUT" CID=$(docker create "$TAG_NAME")
docker cp "$CID":/opt/app "$EAP_OUTPUT"
docker rm "$CID" >/dev/null
# Move all .eap files to releases folder, append variant if needed # Move all .eap files to releases folder, append variant if needed
find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do
@@ -122,14 +168,14 @@ jobs:
# Clean up # Clean up
rm -rf build tailscale_bins *.tgz rm -rf build tailscale_bins *.tgz
# 5. Commit updated manifests and .eap files directly to main # 6. Commit updated manifests and .eap files directly to main
- name: Commit updates to main - name: Commit updates to main
if: env.build_needed == 'true' if: env.build_needed == 'true'
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
# Only commit manifests and ACAP 3 package.conf; do not track release artifacts
git add */app/manifest.json releases/* git add */app/manifest.json arm_acap3/app/package.conf
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No changes to commit" echo "No changes to commit"
else else
@@ -137,7 +183,7 @@ jobs:
git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/Mo3he/Axis_Cam_Tailscale.git main git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/Mo3he/Axis_Cam_Tailscale.git main
fi fi
# 6. Create GitHub Release with all new .eap files # 7. Create GitHub Release with all new .eap files
- name: Create GitHub Release - name: Create GitHub Release
if: env.build_needed == 'true' if: env.build_needed == 'true'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
+12
View File
@@ -1,2 +1,14 @@
**/.DS_Store **/.DS_Store
**/build **/build
# Do not track release artifacts
releases/
build/
# Do not track downloaded Tailscale tarballs and temp bins
tailscale_bins/
*.tgz
# Tailscale binaries - downloaded fresh by CI at build time, not stored in git
*/app/lib/tailscale
*/app/lib/tailscaled
+125 -51
View File
@@ -1,36 +1,45 @@
# Tailscale Installer ACAP for Axis Cameras # Tailscale ACAP for Axis Cameras
This repository contains an **ACAP package** that installs the [Tailscale VPN client](https://tailscale.com/) on Axis cameras. **[Visit the Homepage](https://mo3he.github.io/Axis_Cam_Tailscale/)**
- ✅ Secure remote access to cameras This repository provides an **ACAP package** that installs the [Tailscale VPN client](https://tailscale.com/) on Axis cameras.
- ✅ Easy to install via EAP package
- ✅ Works on **Axis OS 12+** (non-root version) - Secure remote access to cameras
- ✅ Based on **WireGuard VPN** technology - Easy to install via EAP package
- Works on **Axis OS 11.11+** (non-root version)
- Works on **legacy Axis OS 9.x / 10.x** via the ACAP 3 variant
- Based on **WireGuard VPN** technology
[![Releases](https://img.shields.io/github/v/release/Mo3he/Axis_Cam_Tailscale)](https://github.com/Mo3he/Axis_Cam_Tailscale/releases) [![Releases](https://img.shields.io/github/v/release/Mo3he/Axis_Cam_Tailscale)](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
[![License](https://img.shields.io/github/license/Mo3he/Axis_Cam_Tailscale)](LICENSE) [![License](https://img.shields.io/github/license/Mo3he/Axis_Cam_Tailscale)](LICENSE)
[![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-lightgrey?logo=github)](https://github.com/sponsors/Mo3he) ![Total Downloads](https://img.shields.io/github/downloads/Mo3he/Axis_Cam_Tailscale/total?style=flat&label=Downloads&color=blue)
[![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-lightgrey?logo=github)](https://github.com/sponsors/Mo3he)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-orange?style=flat&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/mo3he)
> **Disclaimer:** This is an independent, community-developed ACAP package and is not an official Axis Communications product. It is not affiliated with, endorsed by, or supported by Axis Communications AB. Use it at your own risk. For official Axis software, visit axis.com
> **Tailscale Notice:** Tailscale is a product of Tailscale Inc. This package independently redistributes the Tailscale binaries under the [BSD 3-Clause License](LICENSE) and is not affiliated with, endorsed by, or supported by Tailscale Inc. For the official Tailscale client, visit [tailscale.com](https://tailscale.com).
---
## Table of Contents
- [Installation](#installation)
- [Usage](#usage)
- [Settings](#settings)
- [Proxy Support](#proxy-support)
- [Updating Tailscale](#updating-tailscale)
- [Purpose](#purpose)
- [Useful Links](#useful-links)
- [Compatibility](#compatibility)
- [Roadmap](#roadmap)
- [Star History](#star-history)
- [Support](#support)
--- ---
## 📑 Table of Contents ## Installation
- [📥 Installation](#-installation) Get the **prebuilt `.eap` file** from the [Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
- [🚀 Usage](#-usage)
- [🔄 Updating Tailscale](#-updating-tailscale)
- [🧪 Testers Needed](#-testers-needed)
- [🎉 Good News](#-good-news)
- [🎯 Purpose](#-purpose)
- [🔗 Useful Links](#-useful-links)
- [🖥️ Compatibility](#️-compatibility)
- [⭐ Star History](#-star-history)
- [💖 Support](#-support)
---
## 📥 Installation
The recommended way is to use the **prebuilt `.eap` file** from the [Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
1. Log into your Axis camera. 1. Log into your Axis camera.
2. Go to **Apps → Add App**. 2. Go to **Apps → Add App**.
@@ -41,19 +50,60 @@ Once installed:
- Click **Open** to view logs and get your Tailscale authentication URL. - Click **Open** to view logs and get your Tailscale authentication URL.
- On uninstall, all changes/files are removed. - On uninstall, all changes/files are removed.
> ⚠️ You’ll need a [Tailscale account](https://tailscale.com/) to authenticate. > You'll need a [Tailscale account](https://tailscale.com/) to authenticate.
--- ---
## 🚀 Usage ## Usage
- Runs a startup script to set permissions and launch Tailscale. - Runs a C-based parameter bridge (compiled via ACAP SDK 1.15.1) that reads settings from the ACAP parameter store and launches Tailscale.
- View logs via the **Open** button in the app. - View logs and connection status via the **Open** button in the app.
- Authenticate using the provided URL. - Authenticate using the provided URL, or pre-enter an auth key in **Settings**.
- Change the **Custom Server URL** in Settings to use a self-hosted [Headscale](https://headscale.net/) control server.
- Parameter changes (ports, server URL, auth key) are applied automatically without needing to reinstall the app.
--- ---
## 🔄 Updating Tailscale ## Settings
All parameters are configurable via the web UI (**Open → Settings** card) and take effect immediately without reinstalling:
| Parameter | Default | Description |
|---|---|---|
| Custom Server URL | *(empty)* | Control server URL for [Headscale](https://headscale.net/) or other self-hosted servers. Leave blank to use Tailscale's official servers. |
| Auth Key | *(empty)* | Pre-authentication key (`tskey-auth-...`). Cleared automatically after first successful connection. Leave blank to authenticate via browser. |
| HTTP Proxy Port | `8080` | Port for the outbound HTTP/HTTPS proxy. |
| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 proxy. |
| Accept DNS | `off` | Passes `--accept-dns=true` to `tailscale up`. Allows the tailnet to push DNS settings to the camera. Off by default to avoid overriding the camera's DNS configuration. Not available on `armv7hf_acap3`. |
| Accept Routes | `off` | Passes `--accept-routes=true` to `tailscale up`. Allows the camera to use subnet routes advertised by other nodes in the tailnet. Not available on `armv7hf_acap3`. |
---
All non-ROOT variants expose two local proxy endpoints that route outbound traffic through the Tailscale tunnel. The ports are configurable via **Settings → HTTP Proxy Port / SOCKS5 Proxy Port** in the web UI.
### HTTP CONNECT Proxy — `http://127.0.0.1:8080` (default)
Routes HTTP and HTTPS traffic. Set this wherever an HTTP/HTTPS proxy field is available on the camera:
| Location | Field | Value |
|---|---|---|
| System → Network → Global proxies | HTTP proxy | `http://127.0.0.1:<port>` |
| System → Network → Global proxies | HTTPS proxy | `http://127.0.0.1:<port>` |
| System → MQTT → Broker | HTTP proxy | `http://127.0.0.1:<port>` |
| System → MQTT → Broker | HTTPS proxy | `http://127.0.0.1:<port>` |
### SOCKS5 Proxy — `127.0.0.1:1080` (default)
For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:<port>`.
> The active proxy addresses are always shown in the **Proxy Configuration** card of the web UI.
> If you change a port that is already in use by another process, the app will log an error and exit rather than silently falling back to a different port.
---
## Updating Tailscale
- New `.eap` files are auto-built and released **weekly** (if a new Tailscale version is available). - New `.eap` files are auto-built and released **weekly** (if a new Tailscale version is available).
- To update, simply install the new `.eap` over the existing one. - To update, simply install the new `.eap` over the existing one.
@@ -77,37 +127,31 @@ docker cp $(docker create <package_name>):/opt/app ./build
--- ---
## 🧪 Testers Needed ## Good News
A new **custom** version is available: Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 11.11+**.
- Allows setting a custom server and auth key (for [Headscale](https://headscale.net/)).
- Go to **Settings (⋮ → Settings)** to add your details.
Please give it a try and share your feedback!
---
## 🎉 Good News
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 12+**.
- Runs in **user space networking mode**. - Runs in **user space networking mode**.
For **full networking features**, use the **ROOT** version on Axis OS < 12. For **full kernel networking**, use the **ROOT** version. Note: ROOT mode requires Axis OS 11.11–11.x — Axis OS 12 and later removed root access for third-party applications.
### Legacy camera support (Axis OS 9.x / 10.x)
An **ACAP 3** variant (`armv7hf_acap3`) is available for older cameras that do not support ACAP 4 / Axis OS 11+. It uses the same userspace networking mode and web UI, built against the ACAP SDK 3.5 toolchain.
--- ---
## 🎯 Purpose ## Purpose
Adding a VPN client directly to the camera enables: Adding a VPN client directly to the camera enables:
- Secure remote access without additional hardware or complex network configuration. - Secure remote access without additional hardware or complex network configuration.
- Easy setup through Tailscale’s lightweight WireGuard-based tunnel. - Easy setup through Tailscale’s lightweight WireGuard-based tunnel.
🔗 Learn more: [How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/) Learn more: [How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/)
--- ---
## 🔗 Useful Links ## Useful Links
- [Tailscale](https://tailscale.com/) - [Tailscale](https://tailscale.com/)
- [Tailscale GitHub](https://github.com/tailscale/tailscale) - [Tailscale GitHub](https://github.com/tailscale/tailscale)
@@ -116,10 +160,20 @@ Adding a VPN client directly to the camera enables:
--- ---
## 🖥️ Compatibility ## Compatibility
The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**-based SoCs. The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**-based SoCs.
| Variant | Architecture | Axis OS | Notes |
|---|---|---|---|
| `aarch64` | AArch64 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports |
| `armv7hf` | ARMv7 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports |
| `aarch64_root` | AArch64 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
| `armv7hf_root` | ARMv7 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
| `armv7hf_acap3` | ARMv7 | **9.x – 10.x** | Legacy cameras, ACAP SDK 3 |
> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 12+ → use the standard variant (`aarch64` or `armv7hf`). Axis OS 11.11–11.x → standard variant, or ROOT if you need kernel networking. Axis OS 9.x/10.x on ARMv7 → use `armv7hf_acap3`.
You can verify your device details using the following command: You can verify your device details using the following command:
```bash ```bash
@@ -131,13 +185,33 @@ curl --anyauth "*" -u <username>:<password> <device_ip>/axis-cgi/basicdeviceinfo
--- ---
## ⭐ Star History ## Roadmap
### AXIS OS 13 Preparation
AXIS OS 13 (scheduled for September 2026) introduces several breaking changes that affect all ACAP applications. The following items are required to maintain compatibility. See the full [AXIS OS 13 breaking changes](https://www.axis.com/for-developers/news/AXIS-OS-13-breaking-changes) announcement for details.
- [ ] **Recompile for 64-bit time (Y2038)** - AXIS OS 13 switches to a 64-bit time interface. All ACAP apps must be recompiled against the updated SDK. Cameras with incompatible apps installed will roll back the OS upgrade rather than proceed.
- [ ] **Sign the ACAP via the Axis ACAP Portal** - AXIS OS 13 removes the ability to install unsigned applications in production environments. The app must be submitted and signed through the official Axis ACAP Portal to remain installable.
- [ ] **Migrate to Manifest Schema v2** - The `manifest.json` must use Manifest Schema v2, including an explicit declaration of compatible AXIS OS versions, to satisfy the new signing and compatibility requirements.
- [ ] **Audit for executable stack usage** - Any ACAP compiled with an executable stack must be recompiled to comply with the new security restrictions in AXIS OS 13.
- [ ] **Verify web UI works over HTTPS** - AXIS OS 13 enforces HTTPS-only connections by default. The bundled web UI must be tested to confirm it functions correctly under this constraint.
### General Improvements
- [x] **Accept DNS from tailnet toggle** - Add an opt-in setting to the settings page that passes `--accept-dns=true` to `tailscale up`. Defaults to off to prevent Tailscale from overriding `resolv.conf` on cameras that don't need MagicDNS.
- [x] **Accept routes toggle** - Add an opt-in setting that passes `--accept-routes=true` to `tailscale up`, allowing the camera to use subnet routes advertised by other nodes in the tailnet.
- [ ] **Switch to tiny-tailscale binaries** - Evaluate replacing the bundled `tailscale` and `tailscaled` binaries with [tiny-tailscale](https://github.com/iamromulan/tiny-tailscale) builds. These combine both into a single binary, strip unused features, and are significantly smaller (~43% reduction), reducing install size and memory footprint across all architectures.
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=Mo3he/Axis_Cam_Tailscale&type=Date)](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date) [![Star History Chart](https://api.star-history.com/svg?repos=Mo3he/Axis_Cam_Tailscale&type=Date)](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date)
--- ---
## 💖 Support ## Support
If you like this project and want to support future updates: If you like this project and want to support my work:
👉 [Sponsor Me](https://github.com/sponsors/Mo3he) [Sponsor Me](https://github.com/sponsors/Mo3he)
+3 -2
View File
@@ -1,5 +1,5 @@
ARG ARCH=aarch64 ARG ARCH=aarch64
ARG VERSION=1.3 ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04 ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp ARG REPO=axisecp
ARG SDK=acap-native-sdk ARG SDK=acap-native-sdk
@@ -9,4 +9,5 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application # Building the ACAP application
COPY ./app /opt/app/ COPY ./app /opt/app/
WORKDIR /opt/app WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+14 -1
View File
@@ -1 +1,14 @@
nop: PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
echo "Starting Service"
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscale
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscaled
/usr/local/packages/Tailscale_VPN/lib/tailscaled --socks5-server=localhost:1055 --tun=userspace-networking --socket=/usr/local/packages/Tailscale_VPN/tailscaled.sock &
echo "Service Started"
echo "Scroll to Bottom for link"
/usr/local/packages/Tailscale_VPN/lib/tailscale --socket=/usr/local/packages/Tailscale_VPN/tailscaled.sock up
wait
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
# Tailscale VPN run script — called by the param_bridge C binary.
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Defaults — overridden by sourcing params.conf written by param_bridge
CUSTOM_SERVER=""
AUTH_KEY=""
CONF_HTTP="8080"
CONF_SOCKS="1080"
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
# Check whether a TCP port is already bound
is_port_in_use() {
local port=$1
local hex_port
hex_port=$(printf '%04X' "$port")
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
return 1
}
if is_port_in_use "$CONF_HTTP"; then
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
exit 1
fi
if is_port_in_use "$CONF_SOCKS"; then
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
exit 1
fi
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
--socks5-server=localhost:$CONF_SOCKS \
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
--tun=userspace-networking \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running"
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS"
wait $TAILSCALED_PID
+846 -13
View File
@@ -1,19 +1,852 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<body>
<head> <head>
<h1 style="color:red;">Scroll to the bottom to find authentication URL</h1> <meta charset="UTF-8">
<script> <title>Tailscale VPN</title>
function refreshIFrame() { <style>
var x = document.getElementById("*Your_iframe_id*"); :root {
x.contentWindow.location.reload(); --bg: #0f1117;
var t = setTimeout(refreshIFrame, 5000); --surface: #181b23;
--surface2: #1e2230;
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #2e2d2d;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
--radius: 10px;
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
} }
</script>
[data-theme="light"] {
--bg: #f5f6f8;
--surface: #ffffff;
--surface2: #f0f1f4;
--border: #e0e3e8;
--text: #1a1a2e;
--muted: #6b7084;
--accent: #2e2d2d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 20px;
font-size: 14px;
max-width: 720px;
margin: 0 auto;
line-height: 1.5;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
}
.theme-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
border-radius: 8px;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
.theme-btn svg { width: 16px; height: 16px; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 14px;
}
.card-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 14px;
}
/* Status */
.status-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 14px;
font-weight: 600;
}
.status-banner.connected .status-text { color: var(--green); }
.status-banner.connecting .status-text { color: var(--yellow); }
.status-banner.disconnected .status-text { color: var(--red); }
.status-time {
margin-left: auto;
font-size: 12px;
color: var(--muted);
font-family: var(--mono);
}
/* Auth block */
.auth-block {
background: rgba(245,158,11,0.08);
border: 1px solid rgba(245,158,11,0.2);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.auth-block p {
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
.auth-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 13px;
padding: 8px 18px;
border-radius: 6px;
margin-bottom: 8px;
}
.auth-btn:hover { opacity: 0.9; }
.auth-url {
display: block;
font-size: 11px;
color: var(--muted);
word-break: break-all;
font-family: var(--mono);
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
background: var(--surface2);
border-radius: 8px;
padding: 12px 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--muted);
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
font-weight: 600;
font-family: var(--mono);
word-break: break-all;
}
.info-value.dim { color: var(--muted); font-weight: 400; }
/* Log viewer */
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.log-badge {
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.log-toggle {
font-size: 12px;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
font-weight: 600;
}
.log-toggle:hover { text-decoration: underline; }
.log-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
max-height: 400px;
overflow-y: auto;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--muted);
white-space: pre-wrap;
word-break: break-all;
}
.log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */
.refresh-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(46,45,45,0.1);
border: 1px solid rgba(46,45,45,0.2);
}
.update-banner.visible { display: flex; }
.update-banner .update-text {
flex: 1;
font-size: 13px;
color: var(--text);
}
.update-banner .update-text strong { color: var(--accent); }
.update-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 12px;
padding: 6px 14px;
border-radius: 6px;
white-space: nowrap;
}
.update-btn:hover { opacity: 0.9; }
@media (max-width: 480px) {
body { padding: 14px; }
.info-grid { grid-template-columns: 1fr; }
}
</style>
</head> </head>
<body onload="refreshIFrame()"> <body>
<iframe id="*Your_iframe_id*" src= "/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
</body> <div class="header">
<div class="header-left">
<h1>Tailscale VPN</h1>
</div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
<!-- Status -->
<div id="status-banner" class="status-banner connecting">
<span class="dot"></span>
<span id="status-text" class="status-text">Checking...</span>
<span id="status-time" class="status-time"></span>
</div>
<!-- Update available -->
<div id="update-banner" class="update-banner">
<div class="update-text">Update available: <strong id="update-version"></strong></div>
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</a>
</div>
<!-- Auth (hidden by default) -->
<div id="auth-block" class="auth-block" style="display:none;">
<p>Authenticate this device to connect to your Tailscale network:</p>
<a id="auth-link" class="auth-btn" href="#" target="_blank">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open Login Page
</a>
<span id="auth-url-text" class="auth-url"></span>
</div>
<!-- Connection Info -->
<div class="card" id="info-card" style="display:none;">
<div class="card-title">Connection Details</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Tailscale IP</div>
<div class="info-value" id="ts-ip">-</div>
</div>
<div class="info-item">
<div class="info-label">Node Name</div>
<div class="info-value" id="ts-node">-</div>
</div>
<div class="info-item">
<div class="info-label">Account</div>
<div class="info-value" id="ts-tailnet">-</div>
</div>
<div class="info-item">
<div class="info-label">Version</div>
<div class="info-value" id="ts-version">-</div>
</div>
</div>
<div style="margin-top:14px;text-align:right;">
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
</div>
</div>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box');
var autoScroll = true;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
// Fallbacks only when syslog has rotated and no state transitions are visible.
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
// (handles stale Running entries in syslog after reinstall or token expiry)
if (isRunning && latestUrl) {
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
}
refresh();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})();
</script>
</body> </body>
</html> </html>
View File
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+29 -7
View File
@@ -1,18 +1,40 @@
{ {
"schemaVersion": "1.3", "schemaVersion": "1.7.0",
"acapPackageConf": { "acapPackageConf": {
"setup": { "setup": {
"appName": "Tailscale_VPN", "appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN", "friendlyName": "Tailscale VPN",
"vendor": "Tailscale - Packaged by Mo3he", "vendor": "Mo3he",
"embeddedSdkVersion": "3.0", "embeddedSdkVersion": "3.0",
"vendorUrl": "https://www.tailscale.com", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "once", "runMode": "respawn",
"version": "1.90.3", "version": "1.98.3",
"architecture": "aarch64" "architecture": "aarch64"
}, },
"configuration": { "configuration": {
"settingPage": "index.html" "settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
}
]
} }
} }
} }
+250
View File
@@ -0,0 +1,250 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN (userspace variant).
*
* Responsibilities:
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
* 2. Write them to CONFIG_FILE so the shell script can source them.
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
* of the child so the new config is picked up.
* Rapid changes within 300 ms are coalesced into a single restart.
* 5. Watchdog: if the child exits unexpectedly, restart it.
*
* Runs as the unprivileged 'sdk' ACAP user — no root required.
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
/* ── watchdog ────────────────────────────────────────────────────────────── */
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
start_child();
}
}
return G_SOURCE_CONTINUE;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
cache_get(&cfg_http_proxy_port, "8080"),
cache_get(&cfg_socks5_port, "1080"),
cache_get(&cfg_custom_server, "(default)"));
}
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
/* ── main ────────────────────────────────────────────────────────────────── */
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting");
/* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+3 -2
View File
@@ -1,5 +1,5 @@
ARG ARCH=aarch64 ARG ARCH=aarch64
ARG VERSION=1.3 ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04 ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp ARG REPO=axisecp
ARG SDK=acap-native-sdk ARG SDK=acap-native-sdk
@@ -9,4 +9,5 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application # Building the ACAP application
COPY ./app /opt/app/ COPY ./app /opt/app/
WORKDIR /opt/app WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+14 -1
View File
@@ -1 +1,14 @@
nop: PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
echo "Starting Service"
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscale
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscaled
/usr/local/packages/Tailscale_VPN/lib/tailscaled &
echo "Service Started"
echo "Scroll to Bottom for link"
/usr/local/packages/Tailscale_VPN/lib/tailscale up --accept-routes
wait
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
# Tailscale VPN run script (ROOT / kernel networking variant).
# Sources config from params.conf written by param_bridge.
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
CUSTOM_SERVER=""
AUTH_KEY=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
wait $TAILSCALED_PID
+846 -13
View File
@@ -1,19 +1,852 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<body>
<head> <head>
<h1 style="color:red;">Scroll to the bottom to find authentication URL</h1> <meta charset="UTF-8">
<script> <title>Tailscale VPN</title>
function refreshIFrame() { <style>
var x = document.getElementById("*Your_iframe_id*"); :root {
x.contentWindow.location.reload(); --bg: #0f1117;
var t = setTimeout(refreshIFrame, 5000); --surface: #181b23;
--surface2: #1e2230;
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #2e2d2d;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
--radius: 10px;
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
} }
</script>
[data-theme="light"] {
--bg: #f5f6f8;
--surface: #ffffff;
--surface2: #f0f1f4;
--border: #e0e3e8;
--text: #1a1a2e;
--muted: #6b7084;
--accent: #2e2d2d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 20px;
font-size: 14px;
max-width: 720px;
margin: 0 auto;
line-height: 1.5;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
}
.theme-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
border-radius: 8px;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
.theme-btn svg { width: 16px; height: 16px; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 14px;
}
.card-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 14px;
}
/* Status */
.status-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 14px;
font-weight: 600;
}
.status-banner.connected .status-text { color: var(--green); }
.status-banner.connecting .status-text { color: var(--yellow); }
.status-banner.disconnected .status-text { color: var(--red); }
.status-time {
margin-left: auto;
font-size: 12px;
color: var(--muted);
font-family: var(--mono);
}
/* Auth block */
.auth-block {
background: rgba(245,158,11,0.08);
border: 1px solid rgba(245,158,11,0.2);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.auth-block p {
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
.auth-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 13px;
padding: 8px 18px;
border-radius: 6px;
margin-bottom: 8px;
}
.auth-btn:hover { opacity: 0.9; }
.auth-url {
display: block;
font-size: 11px;
color: var(--muted);
word-break: break-all;
font-family: var(--mono);
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
background: var(--surface2);
border-radius: 8px;
padding: 12px 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--muted);
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
font-weight: 600;
font-family: var(--mono);
word-break: break-all;
}
.info-value.dim { color: var(--muted); font-weight: 400; }
/* Log viewer */
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.log-badge {
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.log-toggle {
font-size: 12px;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
font-weight: 600;
}
.log-toggle:hover { text-decoration: underline; }
.log-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
max-height: 400px;
overflow-y: auto;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--muted);
white-space: pre-wrap;
word-break: break-all;
}
.log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */
.refresh-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(46,45,45,0.1);
border: 1px solid rgba(46,45,45,0.2);
}
.update-banner.visible { display: flex; }
.update-banner .update-text {
flex: 1;
font-size: 13px;
color: var(--text);
}
.update-banner .update-text strong { color: var(--accent); }
.update-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 12px;
padding: 6px 14px;
border-radius: 6px;
white-space: nowrap;
}
.update-btn:hover { opacity: 0.9; }
@media (max-width: 480px) {
body { padding: 14px; }
.info-grid { grid-template-columns: 1fr; }
}
</style>
</head> </head>
<body onload="refreshIFrame()"> <body>
<iframe id="*Your_iframe_id*" src= "/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
</body> <div class="header">
<div class="header-left">
<h1>Tailscale VPN</h1>
</div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
<!-- Status -->
<div id="status-banner" class="status-banner connecting">
<span class="dot"></span>
<span id="status-text" class="status-text">Checking...</span>
<span id="status-time" class="status-time"></span>
</div>
<!-- Update available -->
<div id="update-banner" class="update-banner">
<div class="update-text">Update available: <strong id="update-version"></strong></div>
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</a>
</div>
<!-- Auth (hidden by default) -->
<div id="auth-block" class="auth-block" style="display:none;">
<p>Authenticate this device to connect to your Tailscale network:</p>
<a id="auth-link" class="auth-btn" href="#" target="_blank">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open Login Page
</a>
<span id="auth-url-text" class="auth-url"></span>
</div>
<!-- Connection Info -->
<div class="card" id="info-card" style="display:none;">
<div class="card-title">Connection Details</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Tailscale IP</div>
<div class="info-value" id="ts-ip">-</div>
</div>
<div class="info-item">
<div class="info-label">Node Name</div>
<div class="info-value" id="ts-node">-</div>
</div>
<div class="info-item">
<div class="info-label">Account</div>
<div class="info-value" id="ts-tailnet">-</div>
</div>
<div class="info-item">
<div class="info-label">Version</div>
<div class="info-value" id="ts-version">-</div>
</div>
</div>
<div style="margin-top:14px;text-align:right;">
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
</div>
</div>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box');
var autoScroll = true;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
// Fallbacks only when syslog has rotated and no state transitions are visible.
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
// (handles stale Running entries in syslog after reinstall or token expiry)
if (isRunning && latestUrl) {
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
}
refresh();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})();
</script>
</body> </body>
</html> </html>
View File
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+19 -7
View File
@@ -1,22 +1,34 @@
{ {
"schemaVersion": "1.3", "schemaVersion": "1.7.0",
"acapPackageConf": { "acapPackageConf": {
"setup": { "setup": {
"appName": "Tailscale_VPN", "appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN", "friendlyName": "Tailscale VPN",
"vendor": "Tailscale - Packaged by Mo3he", "vendor": "Mo3he",
"embeddedSdkVersion": "3.0", "embeddedSdkVersion": "3.0",
"user": { "user": {
"group": "root", "group": "root",
"username": "root" "username": "root"
}, },
"vendorUrl": "https://www.tailscale.com", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "once", "runMode": "respawn",
"version": "1.90.3", "version": "1.98.3",
"architecture": "aarch64" "architecture": "aarch64"
}, },
"configuration": { "configuration": {
"settingPage": "index.html" "settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
}
]
} }
} }
} }
+214
View File
@@ -0,0 +1,214 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
* Same structure as regular param_bridge.c but without proxy port params.
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
start_child();
}
}
return G_SOURCE_CONTINUE;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
}
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting (root mode)");
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
-12
View File
@@ -1,12 +0,0 @@
ARG ARCH=aarch64
ARG VERSION=12.3.0
ARG UBUNTU_VERSION=24.04
ARG REPO=axisecp
ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY ./app /opt/app/
WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build .
-5
View File
@@ -1,5 +0,0 @@
To build, from main directory
docker build --tag aarch64 .
docker cp $(docker create aarch64):/opt/app ./build
-29
View File
@@ -1,29 +0,0 @@
PROGS = serverconfig
SRCS = config_updater.c
OBJS = $(SRCS:.c=.o)
PKGS = glib-2.0 gio-2.0 axparameter
CFLAGS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags $(PKGS))
LDLIBS += $(shell PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs $(PKGS))
CFLAGS += -Wall \
-Wextra \
-Wformat=2 \
-Wpointer-arith \
-Wbad-function-cast \
-Wstrict-prototypes \
-Wmissing-prototypes \
-Winline \
-Wdisabled-optimization \
-Wfloat-equal \
-W \
-Werror
all: $(PROGS)
$(PROGS): $(OBJS)
$(CC) $(LDFLAGS) $^ $(LIBS) $(LDLIBS) -o $@
clean:
rm -f $(PROGS) *.o *.eap* *_LICENSE.txt package.conf* param.conf tmp*
-232
View File
@@ -1,232 +0,0 @@
/**
* Simple file-based configuration updater for Tailscale
* This avoids the AXParameter system and just writes directly to a config file
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#define APP_NAME "serverconfig"
#define CONFIG_FILE "/usr/local/packages/serverconfig/config.txt"
#define SCRIPT_PATH "/usr/local/packages/serverconfig/start_tailscale.sh"
#define SCRIPT_SOURCE "/usr/local/packages/serverconfig/lib/start_tailscale.sh"
static gboolean signal_handler(gpointer loop) {
g_main_loop_quit((GMainLoop*)loop);
syslog(LOG_INFO, "Configuration updater stopping.");
return G_SOURCE_REMOVE;
}
// Copy script from lib folder to main directory
static void copy_script_file(void) {
char buffer[4096];
ssize_t bytes_read, bytes_written;
int source_fd, dest_fd;
syslog(LOG_INFO, "Copying script from %s to %s", SCRIPT_SOURCE, SCRIPT_PATH);
// Open source file
source_fd = open(SCRIPT_SOURCE, O_RDONLY);
if (source_fd < 0) {
syslog(LOG_ERR, "Failed to open source script: %s", strerror(errno));
return;
}
// Open destination file (create if doesn't exist, truncate if exists)
dest_fd = open(SCRIPT_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0755);
if (dest_fd < 0) {
syslog(LOG_ERR, "Failed to open destination script: %s", strerror(errno));
close(source_fd);
return;
}
// Copy the file
while ((bytes_read = read(source_fd, buffer, sizeof(buffer))) > 0) {
bytes_written = write(dest_fd, buffer, bytes_read);
if (bytes_written != bytes_read) {
syslog(LOG_ERR, "Error writing to destination file: %s", strerror(errno));
close(source_fd);
close(dest_fd);
return;
}
}
// Close file descriptors
close(source_fd);
close(dest_fd);
// Make the script executable
if (chmod(SCRIPT_PATH, 0755) != 0) {
syslog(LOG_ERR, "Failed to make script executable: %s", strerror(errno));
return;
}
syslog(LOG_INFO, "Script copied and made executable successfully");
}
// Execute the Tailscale script
static void start_tailscale(void) {
syslog(LOG_INFO, "Starting Tailscale VPN script");
// Check if script exists, if not, copy it
struct stat st;
if (stat(SCRIPT_PATH, &st) != 0) {
syslog(LOG_INFO, "Script not found at %s, copying from lib folder", SCRIPT_PATH);
copy_script_file();
}
// Fork and execute the script
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "Failed to fork for Tailscale script: %s", strerror(errno));
return;
} else if (pid == 0) {
// Child process - execute the script
execl(SCRIPT_PATH, "start_tailscale.sh", NULL);
// If we get here, execl failed
syslog(LOG_ERR, "Failed to execute Tailscale script: %s", strerror(errno));
_exit(1);
}
syslog(LOG_INFO, "Tailscale script started with PID: %d", pid);
}
// Update the configuration file with current parameter values
static void update_config_file(AXParameter* handle) {
GError* error = NULL;
gchar* server_value = NULL;
gchar* key_value = NULL;
FILE* file;
// Get parameter values
if (!ax_parameter_get(handle, "CustomServer", &server_value, &error)) {
syslog(LOG_ERR, "Failed to get CustomServer: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
error = NULL;
server_value = g_strdup("");
}
if (!ax_parameter_get(handle, "AuthKey", &key_value, &error)) {
syslog(LOG_ERR, "Failed to get AuthKey: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
key_value = g_strdup("");
}
// Write to config file
file = fopen(CONFIG_FILE, "w");
if (file) {
fprintf(file, "custom_server=%s\n", server_value ? server_value : "");
fprintf(file, "auth_key=%s\n", key_value ? key_value : "");
fclose(file);
// Set permissions to ensure the file is readable
chmod(CONFIG_FILE, 0644);
syslog(LOG_INFO, "Updated configuration file: custom_server=%s",
server_value ? server_value : "");
syslog(LOG_INFO, "Updated configuration file: auth_key=%s",
key_value && strlen(key_value) > 0 ? "(set)" : "(empty)");
} else {
syslog(LOG_ERR, "Failed to open config file for writing");
}
// Clean up
g_free(server_value);
g_free(key_value);
}
// Handle parameter changes
static void parameter_changed(const gchar* name, const gchar* value, gpointer handle_void_ptr) {
AXParameter* handle = handle_void_ptr;
// Extract simple parameter name from the fully qualified name
const char* simple_name = name;
const char* prefix = "root." APP_NAME ".";
if (strncmp(name, prefix, strlen(prefix)) == 0) {
simple_name = name + strlen(prefix);
}
syslog(LOG_INFO, "Parameter changed: %s = %s", simple_name, value);
// Update config file whenever any parameter changes
update_config_file(handle);
// Restart Tailscale to apply the new settings
start_tailscale();
}
int main(void) {
GError* error = NULL;
GMainLoop* loop = NULL;
// Open syslog for logging
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "Config updater starting");
// Initialize parameter handling
AXParameter* handle = ax_parameter_new(APP_NAME, &error);
if (handle == NULL) {
syslog(LOG_ERR, "Failed to initialize parameters: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
exit(1);
}
// Ensure script is copied from lib folder
copy_script_file();
// Create initial config file
update_config_file(handle);
// Start Tailscale VPN script
start_tailscale();
// Register for parameter changes
if (!ax_parameter_register_callback(handle, "CustomServer", parameter_changed, handle, &error)) {
syslog(LOG_ERR, "Failed to register CustomServer callback: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
error = NULL;
}
if (!ax_parameter_register_callback(handle, "AuthKey", parameter_changed, handle, &error)) {
syslog(LOG_ERR, "Failed to register AuthKey callback: %s",
error ? error->message : "unknown error");
if (error) g_error_free(error);
}
// Register for parameter changes with fully qualified names as fallback
if (!ax_parameter_register_callback(handle, "root." APP_NAME ".CustomServer", parameter_changed, handle, NULL)) {
syslog(LOG_INFO, "Fallback CustomServer registration failed (this may be normal)");
}
if (!ax_parameter_register_callback(handle, "root." APP_NAME ".AuthKey", parameter_changed, handle, NULL)) {
syslog(LOG_INFO, "Fallback AuthKey registration failed (this may be normal)");
}
// Set up main loop
loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
syslog(LOG_INFO, "Config updater running. Waiting for parameter changes...");
g_main_loop_run(loop);
// Clean up
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
-19
View File
@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<head>
<h1 style="color:red;">Scroll to the bottom to find authentication URL</h1>
<script>
function refreshIFrame() {
var x = document.getElementById("*Your_iframe_id*");
x.contentWindow.location.reload();
var t = setTimeout(refreshIFrame, 5000);
}
</script>
</head>
<body onload="refreshIFrame()">
<iframe id="*Your_iframe_id*" src= "/axis-cgi/admin/systemlog.cgi?appname=serverconfig" style="height:600px;width:100%;border: none;" ></iframe>
</body>
</body>
</html>
-64
View File
@@ -1,64 +0,0 @@
#!/bin/sh
# Make sure this script terminates any existing Tailscale processes before starting new ones
# Kill any existing tailscaled processes
pkill -f tailscaled || true
# Simple script to start Tailscale with custom configuration
CONFIG_FILE="/usr/local/packages/serverconfig/config.txt"
TAILSCALED_PATH="/usr/local/packages/serverconfig/lib/tailscaled"
TAILSCALE_PATH="/usr/local/packages/serverconfig/lib/tailscale"
SOCKET_PATH="/usr/local/packages/serverconfig/tailscaled.sock"
# Log to syslog
logger -t "tailscale_script" "Starting Tailscale VPN service"
# Set execute permissions
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Read configuration (if exists)
CUSTOM_SERVER=""
AUTH_KEY=""
if [ -f "$CONFIG_FILE" ]; then
logger -t "tailscale_script" "Reading configuration from $CONFIG_FILE"
# Read values from config file
while IFS='=' read -r key value; do
case "$key" in
"custom_server") CUSTOM_SERVER="$value" ;;
"auth_key") AUTH_KEY="$value" ;;
esac
done < "$CONFIG_FILE"
fi
# Start tailscaled
logger -t "tailscale_script" "Starting tailscaled daemon"
$TAILSCALED_PATH --socks5-server=localhost:1055 --tun=userspace-networking --socket=$SOCKET_PATH &
TAILSCALED_PID=$!
# Wait for tailscaled to initialize
sleep 2
# Build up the command based on available parameters
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up"
if [ -n "$CUSTOM_SERVER" ]; then
logger -t "tailscale_script" "Using custom server: $CUSTOM_SERVER"
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
logger -t "tailscale_script" "Using authentication key"
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
# Connect to Tailscale network
logger -t "tailscale_script" "Running: $TAILSCALE_CMD"
eval $TAILSCALE_CMD
# Keep the script running to maintain the tailscaled process
logger -t "tailscale_script" "Tailscale VPN is running"
logger -t "tailscale_script" "To change settings, modify parameters in ACAP web interface"
# Wait for tailscaled process to exit
wait $TAILSCALED_PID
-29
View File
@@ -1,29 +0,0 @@
{
"schemaVersion": "1.7.3",
"acapPackageConf": {
"setup": {
"friendlyName": "Tailscale VPN",
"appName": "serverconfig",
"vendor": "Tailscale - Packaged by Mo3he",
"embeddedSdkVersion": "3.0",
"vendorUrl": "https://www.tailscale.com",
"runMode": "respawn",
"version": "1.90.3"
},
"configuration": {
"settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
}
]
}
}
}
+3 -2
View File
@@ -1,5 +1,5 @@
ARG ARCH=armv7hf ARG ARCH=armv7hf
ARG VERSION=1.3 ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04 ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp ARG REPO=axisecp
ARG SDK=acap-native-sdk ARG SDK=acap-native-sdk
@@ -9,4 +9,5 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application # Building the ACAP application
COPY ./app /opt/app/ COPY ./app /opt/app/
WORKDIR /opt/app WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+14 -1
View File
@@ -1 +1,14 @@
nop: PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
echo "Starting Service"
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscale
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscaled
/usr/local/packages/Tailscale_VPN/lib/tailscaled --socks5-server=localhost:1055 --tun=userspace-networking --socket=/usr/local/packages/Tailscale_VPN/tailscaled.sock &
echo "Service Started"
echo "Scroll to Bottom for link"
/usr/local/packages/Tailscale_VPN/lib/tailscale --socket=/usr/local/packages/Tailscale_VPN/tailscaled.sock up
wait
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
# Tailscale VPN run script — called by the param_bridge C binary.
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Defaults — overridden by sourcing params.conf written by param_bridge
CUSTOM_SERVER=""
AUTH_KEY=""
CONF_HTTP="8080"
CONF_SOCKS="1080"
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
# Check whether a TCP port is already bound
is_port_in_use() {
local port=$1
local hex_port
hex_port=$(printf '%04X' "$port")
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
return 1
}
if is_port_in_use "$CONF_HTTP"; then
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
exit 1
fi
if is_port_in_use "$CONF_SOCKS"; then
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
exit 1
fi
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
--socks5-server=localhost:$CONF_SOCKS \
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
--tun=userspace-networking \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running"
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS"
wait $TAILSCALED_PID
+846 -13
View File
@@ -1,19 +1,852 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<body>
<head> <head>
<h1 style="color:red;">Scroll to the bottom to find authentication URL</h1> <meta charset="UTF-8">
<script> <title>Tailscale VPN</title>
function refreshIFrame() { <style>
var x = document.getElementById("*Your_iframe_id*"); :root {
x.contentWindow.location.reload(); --bg: #0f1117;
var t = setTimeout(refreshIFrame, 5000); --surface: #181b23;
--surface2: #1e2230;
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #2e2d2d;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
--radius: 10px;
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
} }
</script>
[data-theme="light"] {
--bg: #f5f6f8;
--surface: #ffffff;
--surface2: #f0f1f4;
--border: #e0e3e8;
--text: #1a1a2e;
--muted: #6b7084;
--accent: #2e2d2d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 20px;
font-size: 14px;
max-width: 720px;
margin: 0 auto;
line-height: 1.5;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
}
.theme-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
border-radius: 8px;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
.theme-btn svg { width: 16px; height: 16px; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 14px;
}
.card-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 14px;
}
/* Status */
.status-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 14px;
font-weight: 600;
}
.status-banner.connected .status-text { color: var(--green); }
.status-banner.connecting .status-text { color: var(--yellow); }
.status-banner.disconnected .status-text { color: var(--red); }
.status-time {
margin-left: auto;
font-size: 12px;
color: var(--muted);
font-family: var(--mono);
}
/* Auth block */
.auth-block {
background: rgba(245,158,11,0.08);
border: 1px solid rgba(245,158,11,0.2);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.auth-block p {
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
.auth-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 13px;
padding: 8px 18px;
border-radius: 6px;
margin-bottom: 8px;
}
.auth-btn:hover { opacity: 0.9; }
.auth-url {
display: block;
font-size: 11px;
color: var(--muted);
word-break: break-all;
font-family: var(--mono);
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
background: var(--surface2);
border-radius: 8px;
padding: 12px 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--muted);
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
font-weight: 600;
font-family: var(--mono);
word-break: break-all;
}
.info-value.dim { color: var(--muted); font-weight: 400; }
/* Log viewer */
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.log-badge {
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.log-toggle {
font-size: 12px;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
font-weight: 600;
}
.log-toggle:hover { text-decoration: underline; }
.log-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
max-height: 400px;
overflow-y: auto;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--muted);
white-space: pre-wrap;
word-break: break-all;
}
.log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */
.refresh-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(46,45,45,0.1);
border: 1px solid rgba(46,45,45,0.2);
}
.update-banner.visible { display: flex; }
.update-banner .update-text {
flex: 1;
font-size: 13px;
color: var(--text);
}
.update-banner .update-text strong { color: var(--accent); }
.update-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 12px;
padding: 6px 14px;
border-radius: 6px;
white-space: nowrap;
}
.update-btn:hover { opacity: 0.9; }
@media (max-width: 480px) {
body { padding: 14px; }
.info-grid { grid-template-columns: 1fr; }
}
</style>
</head> </head>
<body onload="refreshIFrame()"> <body>
<iframe id="*Your_iframe_id*" src= "/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
</body> <div class="header">
<div class="header-left">
<h1>Tailscale VPN</h1>
</div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
<!-- Status -->
<div id="status-banner" class="status-banner connecting">
<span class="dot"></span>
<span id="status-text" class="status-text">Checking...</span>
<span id="status-time" class="status-time"></span>
</div>
<!-- Update available -->
<div id="update-banner" class="update-banner">
<div class="update-text">Update available: <strong id="update-version"></strong></div>
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</a>
</div>
<!-- Auth (hidden by default) -->
<div id="auth-block" class="auth-block" style="display:none;">
<p>Authenticate this device to connect to your Tailscale network:</p>
<a id="auth-link" class="auth-btn" href="#" target="_blank">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open Login Page
</a>
<span id="auth-url-text" class="auth-url"></span>
</div>
<!-- Connection Info -->
<div class="card" id="info-card" style="display:none;">
<div class="card-title">Connection Details</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Tailscale IP</div>
<div class="info-value" id="ts-ip">-</div>
</div>
<div class="info-item">
<div class="info-label">Node Name</div>
<div class="info-value" id="ts-node">-</div>
</div>
<div class="info-item">
<div class="info-label">Account</div>
<div class="info-value" id="ts-tailnet">-</div>
</div>
<div class="info-item">
<div class="info-label">Version</div>
<div class="info-value" id="ts-version">-</div>
</div>
</div>
<div style="margin-top:14px;text-align:right;">
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
</div>
</div>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box');
var autoScroll = true;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
// Fallbacks only when syslog has rotated and no state transitions are visible.
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
// (handles stale Running entries in syslog after reinstall or token expiry)
if (isRunning && latestUrl) {
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
}
refresh();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})();
</script>
</body> </body>
</html> </html>
View File
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+29 -7
View File
@@ -1,18 +1,40 @@
{ {
"schemaVersion": "1.3", "schemaVersion": "1.7.0",
"acapPackageConf": { "acapPackageConf": {
"setup": { "setup": {
"appName": "Tailscale_VPN", "appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN", "friendlyName": "Tailscale VPN",
"vendor": "Tailscale - Packaged by Mo3he", "vendor": "Mo3he",
"embeddedSdkVersion": "3.0", "embeddedSdkVersion": "3.0",
"vendorUrl": "https://www.tailscale.com", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "once", "runMode": "respawn",
"version": "1.90.3", "version": "1.98.3",
"architecture": "armv7hf" "architecture": "armv7hf"
}, },
"configuration": { "configuration": {
"settingPage": "index.html" "settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
}
]
} }
} }
} }
+250
View File
@@ -0,0 +1,250 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN (userspace variant).
*
* Responsibilities:
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
* 2. Write them to CONFIG_FILE so the shell script can source them.
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
* of the child so the new config is picked up.
* Rapid changes within 300 ms are coalesced into a single restart.
* 5. Watchdog: if the child exits unexpectedly, restart it.
*
* Runs as the unprivileged 'sdk' ACAP user — no root required.
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static AXParameter *g_ax_handle = NULL;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
/* ── watchdog ────────────────────────────────────────────────────────────── */
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
start_child();
}
}
return G_SOURCE_CONTINUE;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
cache_get(&cfg_http_proxy_port, "8080"),
cache_get(&cfg_socks5_port, "1080"),
cache_get(&cfg_custom_server, "(default)"));
}
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
/* ── main ────────────────────────────────────────────────────────────────── */
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting");
/* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+3 -2
View File
@@ -1,5 +1,5 @@
ARG ARCH=armv7hf ARG ARCH=armv7hf
ARG VERSION=1.3 ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04 ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp ARG REPO=axisecp
ARG SDK=acap-native-sdk ARG SDK=acap-native-sdk
@@ -9,4 +9,5 @@ FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application # Building the ACAP application
COPY ./app /opt/app/ COPY ./app /opt/app/
WORKDIR /opt/app WORKDIR /opt/app
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./ RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+14 -1
View File
@@ -1 +1,14 @@
nop: PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
echo "Starting Service"
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscale
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscaled
/usr/local/packages/Tailscale_VPN/lib/tailscaled &
echo "Service Started"
echo "Scroll to Bottom for link"
/usr/local/packages/Tailscale_VPN/lib/tailscale up --accept-routes
wait
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
# Tailscale VPN run script (ROOT / kernel networking variant).
# Sources config from params.conf written by param_bridge.
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
CUSTOM_SERVER=""
AUTH_KEY=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
wait $TAILSCALED_PID
+846 -13
View File
@@ -1,19 +1,852 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<body>
<head> <head>
<h1 style="color:red;">Scroll to the bottom to find authentication URL</h1> <meta charset="UTF-8">
<script> <title>Tailscale VPN</title>
function refreshIFrame() { <style>
var x = document.getElementById("*Your_iframe_id*"); :root {
x.contentWindow.location.reload(); --bg: #0f1117;
var t = setTimeout(refreshIFrame, 5000); --surface: #181b23;
--surface2: #1e2230;
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #2e2d2d;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
--radius: 10px;
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
} }
</script>
[data-theme="light"] {
--bg: #f5f6f8;
--surface: #ffffff;
--surface2: #f0f1f4;
--border: #e0e3e8;
--text: #1a1a2e;
--muted: #6b7084;
--accent: #2e2d2d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 20px;
font-size: 14px;
max-width: 720px;
margin: 0 auto;
line-height: 1.5;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
}
.theme-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
border-radius: 8px;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
.theme-btn svg { width: 16px; height: 16px; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 14px;
}
.card-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 14px;
}
/* Status */
.status-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 14px;
font-weight: 600;
}
.status-banner.connected .status-text { color: var(--green); }
.status-banner.connecting .status-text { color: var(--yellow); }
.status-banner.disconnected .status-text { color: var(--red); }
.status-time {
margin-left: auto;
font-size: 12px;
color: var(--muted);
font-family: var(--mono);
}
/* Auth block */
.auth-block {
background: rgba(245,158,11,0.08);
border: 1px solid rgba(245,158,11,0.2);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.auth-block p {
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
.auth-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 13px;
padding: 8px 18px;
border-radius: 6px;
margin-bottom: 8px;
}
.auth-btn:hover { opacity: 0.9; }
.auth-url {
display: block;
font-size: 11px;
color: var(--muted);
word-break: break-all;
font-family: var(--mono);
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
background: var(--surface2);
border-radius: 8px;
padding: 12px 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--muted);
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
font-weight: 600;
font-family: var(--mono);
word-break: break-all;
}
.info-value.dim { color: var(--muted); font-weight: 400; }
/* Log viewer */
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.log-badge {
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.log-toggle {
font-size: 12px;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
font-weight: 600;
}
.log-toggle:hover { text-decoration: underline; }
.log-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
max-height: 400px;
overflow-y: auto;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--muted);
white-space: pre-wrap;
word-break: break-all;
}
.log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */
.refresh-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(46,45,45,0.1);
border: 1px solid rgba(46,45,45,0.2);
}
.update-banner.visible { display: flex; }
.update-banner .update-text {
flex: 1;
font-size: 13px;
color: var(--text);
}
.update-banner .update-text strong { color: var(--accent); }
.update-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 12px;
padding: 6px 14px;
border-radius: 6px;
white-space: nowrap;
}
.update-btn:hover { opacity: 0.9; }
@media (max-width: 480px) {
body { padding: 14px; }
.info-grid { grid-template-columns: 1fr; }
}
</style>
</head> </head>
<body onload="refreshIFrame()"> <body>
<iframe id="*Your_iframe_id*" src= "/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
</body> <div class="header">
<div class="header-left">
<h1>Tailscale VPN</h1>
</div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
<!-- Status -->
<div id="status-banner" class="status-banner connecting">
<span class="dot"></span>
<span id="status-text" class="status-text">Checking...</span>
<span id="status-time" class="status-time"></span>
</div>
<!-- Update available -->
<div id="update-banner" class="update-banner">
<div class="update-text">Update available: <strong id="update-version"></strong></div>
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</a>
</div>
<!-- Auth (hidden by default) -->
<div id="auth-block" class="auth-block" style="display:none;">
<p>Authenticate this device to connect to your Tailscale network:</p>
<a id="auth-link" class="auth-btn" href="#" target="_blank">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open Login Page
</a>
<span id="auth-url-text" class="auth-url"></span>
</div>
<!-- Connection Info -->
<div class="card" id="info-card" style="display:none;">
<div class="card-title">Connection Details</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Tailscale IP</div>
<div class="info-value" id="ts-ip">-</div>
</div>
<div class="info-item">
<div class="info-label">Node Name</div>
<div class="info-value" id="ts-node">-</div>
</div>
<div class="info-item">
<div class="info-label">Account</div>
<div class="info-value" id="ts-tailnet">-</div>
</div>
<div class="info-item">
<div class="info-label">Version</div>
<div class="info-value" id="ts-version">-</div>
</div>
</div>
<div style="margin-top:14px;text-align:right;">
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
</div>
</div>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box');
var autoScroll = true;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
// Fallbacks only when syslog has rotated and no state transitions are visible.
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
// (handles stale Running entries in syslog after reinstall or token expiry)
if (isRunning && latestUrl) {
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
}
refresh();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})();
</script>
</body> </body>
</html> </html>
View File
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+19 -7
View File
@@ -1,22 +1,34 @@
{ {
"schemaVersion": "1.3", "schemaVersion": "1.7.0",
"acapPackageConf": { "acapPackageConf": {
"setup": { "setup": {
"appName": "Tailscale_VPN", "appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN", "friendlyName": "Tailscale VPN",
"vendor": "Tailscale - Packaged by Mo3he", "vendor": "Mo3he",
"embeddedSdkVersion": "3.0", "embeddedSdkVersion": "3.0",
"user": { "user": {
"group": "root", "group": "root",
"username": "root" "username": "root"
}, },
"vendorUrl": "https://www.tailscale.com", "vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "once", "runMode": "respawn",
"version": "1.90.3", "version": "1.98.3",
"architecture": "armv7hf" "architecture": "armv7hf"
}, },
"configuration": { "configuration": {
"settingPage": "index.html" "settingPage": "index.html",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
}
]
} }
} }
} }
+214
View File
@@ -0,0 +1,214 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
* Same structure as regular param_bridge.c but without proxy port params.
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static AXParameter *g_ax_handle = NULL;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
start_child();
}
}
return G_SOURCE_CONTINUE;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
}
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting (root mode)");
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+44
View File
@@ -0,0 +1,44 @@
ARG UBUNTU_VERSION=20.04
FROM axisecp/acap-sdk:3.5-armv7hf-ubuntu${UBUNTU_VERSION}
RUN apt-get update -qq && apt-get install -y --no-install-recommends upx-ucl && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY ./app /opt/app/
WORKDIR /opt/app
# Rename the shell startup script (the ELF launcher will take the Tailscale_VPN name)
RUN mv Tailscale_VPN start.sh && chmod +x start.sh
# Compile a minimal ELF launcher as APPNAME.
# ACAP 3 elflibcheck requires an ELF binary and uses pidof(APPNAME) for status.
# We fork+exec start.sh so the parent "Tailscale_VPN" process stays resident,
# making pidof find it and the camera UI correctly show Running/Stopped.
RUN . /opt/axis/acapsdk/environment-setup* && \
${CC} -o Tailscale_VPN launcher.c && \
${STRIP} -s Tailscale_VPN
# Strip then UPX-compress the Tailscale binaries so they fit on flash
RUN . /opt/axis/acapsdk/environment-setup* && \
${STRIP} -s lib/tailscale lib/tailscaled 2>/dev/null || true && \
upx --best lib/tailscale lib/tailscaled
# ACAP 3 firmware expects the settings page at the app root, not in html/
RUN cp html/index.html index.html
# Symlink tailscaled.log into html/ so the web UI can fetch it via HTTP.
# The log is written at runtime to localdata/ (resolved path at runtime).
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log
# Build and package
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
# Patch STARTMODE: create-package.sh hardcodes "never" unless RESTRICTION_STARTMODE is set,
# but the ACAP 3 SDK does not honour our package.conf's STARTMODE=respawn without it.
# Repack the .eap with the corrected value.
RUN for eap in *.eap; do \
tmpdir=$(mktemp -d) && tar xf "$eap" -C "$tmpdir" && \
sed -i 's/STARTMODE="never"/STARTMODE="respawn"/' "$tmpdir/package.conf" && \
(cd "$tmpdir" && tar czf "/opt/app/$eap" .) && \
rm -rf "$tmpdir"; \
done
+1
View File
@@ -0,0 +1 @@
nop:
+65
View File
@@ -0,0 +1,65 @@
#!/bin/sh
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
logger -t "Tailscale_VPN" "Starting Tailscale VPN service (userspace networking)"
mkdir -p "$STATE_DIR"
chmod 755 "$APP_DIR/lib/tailscale"
chmod 755 "$APP_DIR/lib/tailscaled"
# Kill any leftover daemon from a previous run
killall tailscaled 2>/dev/null || true
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
# Log to file (not piped through logger) -- avoids extra logger process holding
# tailscaled stdout open, which prevents our wait loop from detecting exit
"$APP_DIR/lib/tailscaled" \
--state="$STATE_DIR/tailscaled.state" \
--socket="$STATE_DIR/tailscaled.sock" \
--socks5-server=localhost:1055 \
--outbound-http-proxy-listen=localhost:8080 \
--tun=userspace-networking \
>> "$STATE_DIR/tailscaled.log" 2>&1 &
TAILSCALED_PID=$!
# Wait for socket to appear (up to 15 seconds)
i=0
while [ $i -lt 15 ] && [ ! -S "$STATE_DIR/tailscaled.sock" ]; do
sleep 1
i=$((i + 1))
done
logger -t "Tailscale_VPN" "Connecting to Tailscale network"
# --timeout=10s: tailscale up exits promptly after connecting (or giving up),
# preventing two large Go binaries running simultaneously and causing OOM on
# cameras with limited RAM (e.g. 222 MB).
# Capture output so we can extract auth URL and log it to syslog for the web UI.
UP_OUT=$("$APP_DIR/lib/tailscale" \
--socket="$STATE_DIR/tailscaled.sock" \
up --hostname="$(hostname)" --timeout=10s 2>&1) || true
echo "$UP_OUT" >> "$STATE_DIR/tailscaled.log"
# If an auth URL was returned, log it so the web UI can show it
AUTH_URL=$(echo "$UP_OUT" | grep -o 'https://login\.tailscale\.com/[^ ]*' | head -1)
[ -n "$AUTH_URL" ] && logger -t "Tailscale_VPN" "Auth required: $AUTH_URL"
# Log IP and version into syslog so the web UI details panel can populate
TS_IP=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" ip -4 2>/dev/null | head -1)
TS_VER=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" version 2>/dev/null | head -1)
[ -n "$TS_IP" ] && logger -t "Tailscale_VPN" "Tailscale IP: $TS_IP"
[ -n "$TS_VER" ] && logger -t "Tailscale_VPN" "Tailscale version: $TS_VER"
logger -t "Tailscale_VPN" "Tailscale VPN is running"
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
# Monitoring loop: stay alive while tailscaled is running.
# This keeps the parent Tailscale_VPN (C launcher) in the process table
# so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
while kill -0 "$TAILSCALED_PID" 2>/dev/null; do
sleep 5
done
logger -t "Tailscale_VPN" "tailscaled exited"
+697
View File
@@ -0,0 +1,697 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tailscale VPN</title>
<style>
:root {
--bg: #0f1117;
--surface: #181b23;
--surface2: #1e2230;
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #2e2d2d;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
--radius: 10px;
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
}
[data-theme="light"] {
--bg: #f5f6f8;
--surface: #ffffff;
--surface2: #f0f1f4;
--border: #e0e3e8;
--text: #1a1a2e;
--muted: #6b7084;
--accent: #2e2d2d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 20px;
font-size: 14px;
max-width: 720px;
margin: 0 auto;
line-height: 1.5;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
}
.theme-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
border-radius: 8px;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
.theme-btn svg { width: 16px; height: 16px; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 14px;
}
.card-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 14px;
}
/* Status */
.status-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 14px;
font-weight: 600;
}
.status-banner.connected .status-text { color: var(--green); }
.status-banner.connecting .status-text { color: var(--yellow); }
.status-banner.disconnected .status-text { color: var(--red); }
.status-time {
margin-left: auto;
font-size: 12px;
color: var(--muted);
font-family: var(--mono);
}
/* Auth block */
.auth-block {
background: rgba(245,158,11,0.08);
border: 1px solid rgba(245,158,11,0.2);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.auth-block p {
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
.auth-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 13px;
padding: 8px 18px;
border-radius: 6px;
margin-bottom: 8px;
}
.auth-btn:hover { opacity: 0.9; }
.auth-url {
display: block;
font-size: 11px;
color: var(--muted);
word-break: break-all;
font-family: var(--mono);
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
background: var(--surface2);
border-radius: 8px;
padding: 12px 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--muted);
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
font-weight: 600;
font-family: var(--mono);
word-break: break-all;
}
.info-value.dim { color: var(--muted); font-weight: 400; }
/* Log viewer */
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.log-badge {
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.log-toggle {
font-size: 12px;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
font-weight: 600;
}
.log-toggle:hover { text-decoration: underline; }
.log-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
max-height: 400px;
overflow-y: auto;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--muted);
white-space: pre-wrap;
word-break: break-all;
}
.log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.log-line .ts { color: var(--muted); opacity: 0.6; }
.log-line .msg-info { color: var(--accent); }
.log-line .msg-warn { color: var(--yellow); }
.log-line .msg-err { color: var(--red); }
.log-line .msg-ok { color: var(--green); }
/* Refresh indicator */
.refresh-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(46,45,45,0.1);
border: 1px solid rgba(46,45,45,0.2);
}
.update-banner.visible { display: flex; }
.update-banner .update-text {
flex: 1;
font-size: 13px;
color: var(--text);
}
.update-banner .update-text strong { color: var(--accent); }
.update-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 12px;
padding: 6px 14px;
border-radius: 6px;
white-space: nowrap;
}
.update-btn:hover { opacity: 0.9; }
@media (max-width: 480px) {
body { padding: 14px; }
.info-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>Tailscale VPN</h1>
</div>
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
<!-- Status -->
<div id="status-banner" class="status-banner connecting">
<span class="dot"></span>
<span id="status-text" class="status-text">Checking...</span>
<span id="status-time" class="status-time"></span>
</div>
<!-- Update available -->
<div id="update-banner" class="update-banner">
<div class="update-text">Update available: <strong id="update-version"></strong></div>
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</a>
</div>
<!-- Auth (hidden by default) -->
<div id="auth-block" class="auth-block" style="display:none;">
<p>Authenticate this device to connect to your Tailscale network:</p>
<a id="auth-link" class="auth-btn" href="#" target="_blank">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open Login Page
</a>
<span id="auth-url-text" class="auth-url"></span>
</div>
<!-- Connection Info -->
<div class="card" id="info-card" style="display:none;">
<div class="card-title">Connection Details</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Tailscale IP</div>
<div class="info-value" id="ts-ip">-</div>
</div>
<div class="info-item">
<div class="info-label">Node Name</div>
<div class="info-value" id="ts-node">-</div>
</div>
<div class="info-item">
<div class="info-label">Account</div>
<div class="info-value" id="ts-tailnet">-</div>
</div>
<div class="info-item">
<div class="info-label">Version</div>
<div class="info-value" id="ts-version">-</div>
</div>
</div>
<div style="margin-top:14px;text-align:right;">
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box');
var autoScroll = true;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
if (!version) {
// ACAP3: logged by start.sh as "Tailscale version: 1.96.4"
var acap3ver = txt.match(/Tailscale version: (\d+\.\d+\.\d+)/);
if (acap3ver) version = acap3ver[1];
}
// ACAP3: extract IP from "Tailscale IP: 100.x.x.x" logged by start.sh
if (!tsIP) {
var acap3ip = txt.match(/Tailscale IP: (100\.[\d.]+)/);
if (acap3ip) tsIP = acap3ip[1];
}
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
if (isRunning && latestUrl) {
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
// ACAP3: also fetch the raw tailscaled.log (symlinked into html/) so the parser
// can find IP, version, tailnet and Running state from tailscaled's own output.
var DAEMON_LOG_URL = 'tailscaled.log';
function refresh() {
var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); }).catch(function() { return ''; });
var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); }).catch(function() { return ''; });
Promise.all([syslogFetch, daemonFetch]).then(function(res) {
// Syslog provides Axis timestamp headers (node name) and start/stop events.
// tailscaled.log provides IP, version, tailnet, and -> Running state.
var txt = res[0] + '\n' + res[1];
var result = parse(txt);
renderLogs(res[0]); // show syslog in log panel; daemon log is too verbose
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
}).catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
}
refresh();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
})();
</script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
#include <signal.h>
/*
* ACAP 3 supervisor launcher for Tailscale_VPN.
*
* elflibcheck.sh requires APPNAME to be an ELF binary.
* acap-startstop / respawnd / list.cgi all use pidof(APPNAME) for status.
*
* This binary NEVER exits voluntarily — it loops restarting start.sh if it
* dies, so:
* - pidof Tailscale_VPN always finds this process → UI shows "Running"
* - respawnd never triggers (it only fires when APPNAME exits)
* - If tailscaled OOMs and start.sh exits, we cleanly restart it
*
* To stop the app, acap-startstop calls stop_daemon which sends SIGTERM here.
* We forward SIGTERM/SIGINT to the child and then exit.
*/
static volatile int g_stop = 0;
static volatile pid_t g_child = -1;
static void sig_forward(int sig) {
g_stop = 1;
if (g_child > 0)
kill(g_child, sig);
}
int main(void)
{
signal(SIGTERM, sig_forward);
signal(SIGINT, sig_forward);
signal(SIGCHLD, SIG_DFL);
while (!g_stop) {
pid_t pid = fork();
if (pid == 0) {
/* child: reset signals and exec start.sh */
signal(SIGTERM, SIG_DFL);
signal(SIGINT, SIG_DFL);
execl("/usr/local/packages/Tailscale_VPN/start.sh",
"/usr/local/packages/Tailscale_VPN/start.sh", (char *)0);
_exit(127);
}
if (pid < 0) {
sleep(5);
continue;
}
g_child = pid;
int status;
pid_t ret;
do {
ret = waitpid(pid, &status, 0);
} while (ret == -1 && errno == EINTR && !g_stop);
g_child = -1;
if (!g_stop) {
/* start.sh died unexpectedly — wait before restarting */
sleep(3);
}
}
return 0;
}
View File
+14
View File
@@ -0,0 +1,14 @@
PACKAGENAME=Tailscale_VPN
MENUNAME="Tailscale VPN"
VENDOR="Mo3he"
APPMAJORVERSION=1
APPMINORVERSION=98
APPMICROVERSION=3
APPTYPE=armv7hf
APPNAME=Tailscale_VPN
APPOPTS=""
STARTMODE=respawn
OTHERFILES="lib html index.html LICENSE start.sh"
SETTINGSPAGEFILE=index.html
GRPNAME=
USERNAME=root
+592
View File
@@ -0,0 +1,592 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tailscale VPN for Axis Devices</title>
<meta name="description" content="Install Tailscale VPN directly on your Axis device. Secure remote access with WireGuard, no extra hardware needed.">
<!-- Open Graph / LinkedIn -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://mo3he.github.io/Axis_Cam_Tailscale/">
<meta property="og:title" content="Tailscale VPN for Axis Devices">
<meta property="og:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
<meta property="og:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
<meta property="og:image:width" content="1340">
<meta property="og:image:height" content="724">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Tailscale VPN for Axis Devices">
<meta name="twitter:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
<meta name="twitter:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
<style>
:root {
--bg: #0f1117;
--surface: #181b23;
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #4f8ff7;
--accent-hover: #6ba1ff;
--green: #34d399;
--radius: 12px;
}
[data-theme="light"] {
--bg: #f8f9fb;
--surface: #ffffff;
--border: #e0e3e8;
--text: #1a1d27;
--muted: #5f6577;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--green: #059669;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
/* Nav */
nav {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem 2rem;
}
nav .logo {
font-size: 1.2rem;
font-weight: 700;
color: var(--text);
}
nav .links { display: flex; gap: 1.5rem; align-items: center; }
nav .links a { color: var(--muted); font-size: 0.95rem; }
nav .links a:hover { color: var(--text); }
.theme-toggle {
background: none;
border: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
border-radius: 8px;
padding: 0.4rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
width: 34px;
height: 34px;
}
.theme-toggle:hover { border-color: var(--muted); color: var(--text); }
.theme-toggle svg { width: 18px; height: 18px; }
/* Hero */
.hero {
text-align: center;
padding: 5rem 2rem 4rem;
max-width: 800px;
margin: 0 auto;
}
.hero .badge {
display: inline-block;
background: rgba(79, 143, 247, 0.12);
color: var(--accent);
padding: 0.35rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 1.5rem;
border: 1px solid rgba(79, 143, 247, 0.2);
}
.hero h1 {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
margin-bottom: 1.25rem;
}
.hero h1 span { color: var(--accent); }
.hero-word {
display: inline-block;
color: var(--accent);
transition: opacity 0.25s, transform 0.25s;
}
.hero p {
font-size: 1.2rem;
color: var(--muted);
max-width: 600px;
margin: 0 auto 2rem;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); color: #fff; }
.btn-outline {
border: 1px solid var(--border);
color: var(--text);
background: transparent;
}
.btn-outline:hover { border-color: var(--muted); color: var(--text); }
/* Features */
.features {
max-width: 1100px;
margin: 0 auto;
padding: 3rem 2rem 4rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.25rem;
}
.feature-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.75rem;
}
.feature-card .icon {
font-size: 1.6rem;
margin-bottom: 0.75rem;
}
.feature-card h3 {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.feature-card p {
color: var(--muted);
font-size: 0.92rem;
line-height: 1.55;
}
/* Downloads */
.downloads {
max-width: 1100px;
margin: 0 auto;
padding: 3rem 2rem 4rem;
}
.downloads h2 {
text-align: center;
font-size: 2rem;
font-weight: 800;
margin-bottom: 0.5rem;
}
.downloads .subtitle {
text-align: center;
color: var(--muted);
margin-bottom: 2.5rem;
font-size: 1.05rem;
}
.download-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.25rem;
}
.download-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.75rem;
display: flex;
flex-direction: column;
}
.download-card .tag {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.75rem;
width: fit-content;
}
.tag-recommended { background: rgba(52, 211, 153, 0.15); color: var(--green); }
.tag-acap3 { background: rgba(168, 85, 247, 0.15); color: #a855f7; }
.tag-root { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
.download-card h3 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 0.4rem;
}
.download-card p {
color: var(--muted);
font-size: 0.9rem;
margin-bottom: 1.25rem;
flex: 1;
}
.arch-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.arch-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
border: 1px solid var(--border);
color: var(--text);
background: transparent;
transition: all 0.2s;
}
.arch-btn:hover { border-color: var(--accent); color: var(--accent); }
.arch-btn svg { width: 16px; height: 16px; }
/* Install */
.install {
max-width: 700px;
margin: 0 auto;
padding: 3rem 2rem 4rem;
text-align: center;
}
.install h2 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 2rem;
}
.steps {
text-align: left;
display: flex;
flex-direction: column;
gap: 1rem;
}
.step {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.step-num {
min-width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(79, 143, 247, 0.12);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.9rem;
border: 1px solid rgba(79, 143, 247, 0.2);
}
.step-text { padding-top: 0.35rem; }
.step-text strong { display: block; margin-bottom: 0.15rem; }
.step-text span { color: var(--muted); font-size: 0.92rem; }
/* Footer */
footer {
border-top: 1px solid var(--border);
text-align: center;
padding: 2rem;
color: var(--muted);
font-size: 0.85rem;
max-width: 1100px;
margin: 2rem auto 0;
}
footer .footer-links {
display: flex;
gap: 1.5rem;
justify-content: center;
margin-bottom: 0.75rem;
}
@media (max-width: 640px) {
.hero h1 { font-size: 2rem; }
.hero { padding: 3rem 1.25rem 2.5rem; }
.features, .downloads, .install { padding-left: 1.25rem; padding-right: 1.25rem; }
nav { padding: 1rem 1.25rem; flex-wrap: wrap; gap: 0.75rem; }
nav .logo { font-size: 1.05rem; }
nav .links { gap: 1rem; flex-wrap: wrap; justify-content: center; }
nav .links a { font-size: 0.85rem; }
.hero-buttons { flex-direction: column; align-items: center; }
.download-grid { grid-template-columns: 1fr; }
footer .footer-links { flex-wrap: wrap; gap: 1rem; }
}
</style>
</head>
<body>
<nav>
<div class="logo">Tailscale VPN + Axis</div>
<div class="links">
<a href="#features">Features</a>
<a href="#downloads">Downloads</a>
<a href="#install">Install</a>
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener" style="color: #db61a2;">Sponsor</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
</nav>
<!-- Hero -->
<section class="hero">
<div class="badge">Open Source &middot; ACAP Package</div>
<h1>Tailscale VPN for<br>Axis <span class="hero-word" id="heroWord">Cameras</span></h1>
<p>Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.</p>
<div class="hero-buttons">
<a href="#downloads" class="btn btn-primary">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download EAP
</a>
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" class="btn btn-outline" target="_blank" rel="noopener">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.73.083-.73 1.205.085 1.838 1.237 1.838 1.237 1.07 1.834 2.807 1.304 3.492.997.108-.775.418-1.305.762-1.604-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.468-2.382 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23a11.5 11.5 0 0 1 3.003-.404c1.02.005 2.047.138 3.006.404 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.838 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.694.825.576C20.565 21.795 24 17.295 24 12c0-6.63-5.37-12-12-12z"/></svg>
View on GitHub
</a>
</div>
</section>
<!-- Disclaimer -->
<div style="max-width:720px;margin:0 auto;padding:0 1.5rem 1.5rem;">
<div style="background:rgba(79,143,247,0.07);border:1px solid rgba(79,143,247,0.18);border-radius:10px;padding:12px 18px;font-size:12.5px;color:var(--muted);line-height:1.6;">
<strong style="color:var(--text);">Disclaimer:</strong>
This is an independent, community-developed ACAP package and is <strong style="color:var(--text);">not an official Axis Communications product</strong>.
It is not affiliated with, endorsed by, or supported by Axis Communications AB.
Use at your own risk. For official Axis software, visit <a href="https://www.axis.com" target="_blank" rel="noopener">axis.com</a>.
</div>
</div>
<!-- Features -->
<section id="features" class="features">
<div class="feature-card">
<div class="icon">🔒</div>
<h3>WireGuard Encryption</h3>
<p>Built on Tailscale's WireGuard-based tunnel for fast, modern, and auditable encryption on every connection.</p>
</div>
<div class="feature-card">
<div class="icon">⚡</div>
<h3>No Root Required</h3>
<p>Runs in user-space networking mode on Axis OS 12+. No need to enable root access on your device.</p>
</div>
<div class="feature-card">
<div class="icon">📦</div>
<h3>Simple EAP Install</h3>
<p>Upload the .eap file through your device's web interface. Start the app and authenticate - done.</p>
</div>
<div class="feature-card">
<div class="icon">🔄</div>
<h3>Weekly Auto-Updates</h3>
<p>New EAP packages are automatically built and released every week when a new Tailscale version is available.</p>
</div>
<div class="feature-card">
<div class="icon">🌐</div>
<h3>Headscale Compatible</h3>
<p>Supports self-hosted Headscale servers with configurable server URL and auth key, built into every variant.</p>
</div>
<div class="feature-card">
<div class="icon">🔀</div>
<h3>Outbound Proxy</h3>
<p>Allows the device to route its own outbound traffic through Tailscale via a local HTTP/HTTPS and SOCKS5 proxy.</p>
</div>
</section>
<!-- Downloads -->
<section id="downloads" class="downloads">
<h2>Download</h2>
<p class="subtitle">Pick the right variant for your device and Axis OS version.</p>
<div class="download-grid">
<!-- Standard -->
<div class="download-card">
<div class="tag tag-recommended">Recommended</div>
<h3>Standard (Non-Root)</h3>
<p>For Axis OS 12 and newer. Runs in user-space networking mode without root privileges.</p>
<div class="arch-buttons">
<a class="arch-btn" data-asset="Tailscale_VPN" data-arch="aarch64" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
AARCH64
</a>
<a class="arch-btn" data-asset="Tailscale_VPN" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
ARM
</a>
</div>
</div>
<!-- ROOT -->
<div class="download-card">
<div class="tag tag-root">Root &middot; Legacy</div>
<h3>Root (Full Networking)</h3>
<p>For Axis OS older than 12. Requires root access but provides full Tailscale networking features.</p>
<div class="arch-buttons">
<a class="arch-btn" data-asset="ROOT" data-arch="aarch64" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
AARCH64
</a>
<a class="arch-btn" data-asset="ROOT" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
ARM
</a>
</div>
</div>
<!-- ACAP3 -->
<div class="download-card">
<div class="tag tag-acap3">ACAP3 &middot; Legacy</div>
<h3>ACAP3 (Older Axis OS)</h3>
<p>For devices running Axis OS versions that do not support ACAP4. ARM only.</p>
<div class="arch-buttons">
<a class="arch-btn" data-asset="acap3" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
ARM
</a>
</div>
</div>
</div>
</section>
<!-- Install -->
<section id="install" class="install">
<h2>Installation</h2>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<div class="step-text">
<strong>Download the EAP</strong>
<span>Grab the right .eap file for your device architecture from the releases page above.</span>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-text">
<strong>Upload to your device</strong>
<span>Log into your Axis device web interface and go to <strong>Apps &rarr; Add App</strong>. Upload the .eap file.</span>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-text">
<strong>Start &amp; authenticate</strong>
<span>Start the app, click <strong>Open</strong> to view logs and get your Tailscale authentication URL. Sign in and you're connected.</span>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer>
<div class="footer-links">
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases" target="_blank" rel="noopener">Releases</a>
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener">Sponsor</a>
<a href="https://buymeacoffee.com/mo3he" target="_blank" rel="noopener">Buy Me a Coffee</a>
<a href="https://tailscale.com/" target="_blank" rel="noopener">Tailscale</a>
<a href="https://www.axis.com/" target="_blank" rel="noopener">Axis</a>
</div>
<p>&copy; 2025 Mo3he &middot; MIT License</p>
<p style="font-size:0.75rem;opacity:0.5;margin-top:0.5rem;">Tailscale is a product of Tailscale Inc. This is an independent, unofficial package that redistributes Tailscale binaries under the <a href="https://github.com/Mo3he/Axis_Cam_Tailscale/blob/main/LICENSE" target="_blank" rel="noopener" style="color:inherit;">BSD 3-Clause License</a>. Not affiliated with or endorsed by Tailscale Inc. or Axis Communications AB.</p>
</footer>
<script>
(function() {
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var html = document.documentElement;
function applyTheme(theme) {
if (theme === 'light') {
html.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
html.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('theme');
if (stored) {
applyTheme(stored);
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
applyTheme('light');
}
toggle.addEventListener('click', function() {
var isLight = html.getAttribute('data-theme') === 'light';
var next = isLight ? 'dark' : 'light';
localStorage.setItem('theme', next);
applyTheme(next);
});
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function(e) {
if (!localStorage.getItem('theme')) {
applyTheme(e.matches ? 'light' : 'dark');
}
});
// Rotate hero word
var heroWord = document.getElementById('heroWord');
var devices = ['Cameras', 'Door Stations', 'Intercoms', 'Speakers', 'Radars', 'Encoders'];
var wordIdx = 0;
function cycleWord() {
wordIdx = (wordIdx + 1) % devices.length;
heroWord.style.opacity = '0';
heroWord.style.transform = 'translateY(8px)';
setTimeout(function() {
heroWord.textContent = devices[wordIdx];
heroWord.style.opacity = '1';
heroWord.style.transform = 'translateY(0)';
}, 250);
}
setInterval(cycleWord, 3000);
// Rewrite download links to point directly to latest release assets
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var assets = data.assets || [];
var buttons = document.querySelectorAll('a[data-asset]');
for (var i = 0; i < buttons.length; i++) {
var btn = buttons[i];
var type = btn.getAttribute('data-asset');
var arch = btn.getAttribute('data-arch');
for (var j = 0; j < assets.length; j++) {
var name = assets[j].name.toLowerCase();
var archMatch = name.indexOf(arch) !== -1;
var typeMatch = false;
if (type === 'Tailscale_VPN') {
typeMatch = name.indexOf('root') === -1 && name.indexOf('acap3') === -1;
} else if (type === 'acap3') {
typeMatch = name.indexOf('acap3') !== -1;
} else if (type === 'ROOT') {
typeMatch = name.indexOf('root') !== -1;
}
if (archMatch && typeMatch) {
btn.href = assets[j].browser_download_url;
btn.removeAttribute('target');
break;
}
}
}
})
.catch(function() {});
})();
</script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB