mirror of
https://github.com/Mo3he/Axis_Cam_Tailscale.git
synced 2026-10-01 12:05:37 +00:00
Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7d73ca0f | ||
|
|
5080ec39fb | ||
|
|
219ff70c84 | ||
|
|
7a7ecec47c | ||
|
|
3ab196b7b5 | ||
|
|
6be4d7d8ad | ||
|
|
64ee3e49ab | ||
|
|
4df48e8ae6 | ||
|
|
806c3309b6 | ||
|
|
a4deaeacae | ||
|
|
6ba1c1a1a1 | ||
|
|
25df94e489 | ||
|
|
a72987391c | ||
|
|
04907d55bf | ||
|
|
54a1859f14 | ||
|
|
81e38c28b6 | ||
|
|
8643e99833 | ||
|
|
95e4a9aec2 | ||
|
|
fb67b6dff3 | ||
|
|
77fc186d0f | ||
|
|
b789d2e78e | ||
|
|
937b8ede3e | ||
|
|
49898008f6 | ||
|
|
5b09a40231 | ||
|
|
98918ca6f5 | ||
|
|
08b0fdc081 | ||
|
|
f3c6dd54f3 | ||
|
|
c3cbf8d433 | ||
|
|
39a5a42def | ||
|
|
713550d1c0 | ||
|
|
14db783425 | ||
|
|
c02f0aa498 | ||
|
|
96477cdb0d | ||
|
|
4e0eabeec3 | ||
|
|
59161ccd91 | ||
|
|
e1b2ac3490 | ||
|
|
5f03ac918f | ||
|
|
2db9f27181 | ||
|
|
396b450415 | ||
|
|
2d1ff5e8a6 | ||
|
|
7655028477 | ||
|
|
f890d8df1a | ||
|
|
df0756a64d | ||
|
|
df2cf84c6c | ||
|
|
f420e7d24c | ||
|
|
3dc49f1254 | ||
|
|
2005ba32db | ||
|
|
1f9c6a9e45 | ||
|
|
6148fc3298 | ||
|
|
bd60c24b5b | ||
|
|
7926c58b3d | ||
|
|
6bdca7eadd | ||
|
|
8b941b08a5 | ||
|
|
d157a91bae | ||
|
|
b35ed437b5 | ||
|
|
cb390384a1 | ||
|
|
533fc53040 | ||
|
|
0a85c286b4 | ||
|
|
d7e7b63952 | ||
|
|
ed7643b2eb | ||
|
|
c4b24aee40 | ||
|
|
bec473f0a8 | ||
|
|
5278677098 | ||
|
|
c21f640622 | ||
|
|
83e108bb05 | ||
|
|
38d7af6c86 | ||
|
|
51ead4a708 | ||
|
|
123906c98b | ||
|
|
f6999311b7 | ||
|
|
a95415d687 | ||
|
|
7d584d1a22 | ||
|
|
e27f0cdb34 | ||
|
|
e5954eac52 | ||
|
|
91a45be400 | ||
|
|
9b06b49fef | ||
|
|
811aa9e2f5 | ||
|
|
f69a1971a2 | ||
|
|
e01a2ab95f | ||
|
|
5b87555295 | ||
|
|
4a4618344a | ||
|
|
de739dec13 | ||
|
|
ef0cb156be | ||
|
|
f3556d3e76 | ||
|
|
d5e2be837f | ||
|
|
10f5cec1dd | ||
|
|
eb46f0f201 | ||
|
|
5a6aada216 | ||
|
|
65509f037e | ||
|
|
5e27f2f6ca | ||
|
|
f27769f415 | ||
|
|
b8038797f3 | ||
|
|
07f73648bf | ||
|
|
108ea203b4 | ||
|
|
4b282e81e6 | ||
|
|
34943f62b9 | ||
|
|
3d12e28a1f | ||
|
|
b453bcb6f2 | ||
|
|
616d270350 | ||
|
|
17c2ae705c | ||
|
|
54be4dd84f | ||
|
|
452f7d54d8 | ||
|
|
b444c964f6 | ||
|
|
e0178b531e | ||
|
|
46f7a524c0 | ||
|
|
f5ab42fd3f | ||
|
|
019beb3c73 | ||
|
|
db343b4447 | ||
|
|
f3163cae45 | ||
|
|
b7391743d6 | ||
|
|
2b373227d2 | ||
|
|
fa4e6edde1 | ||
|
|
c26643adf0 |
@@ -0,0 +1,186 @@
|
||||
name: Auto Build & Release Tailscale ACAP
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # Every Monday at 03:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# 1. Checkout repo
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
persist-credentials: true
|
||||
fetch-depth: 0
|
||||
|
||||
# 2. Get latest Tailscale version
|
||||
- name: Get latest Tailscale version
|
||||
id: tailscale_version
|
||||
run: |
|
||||
# 1. Start from GitHub latest
|
||||
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 "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get current repo version
|
||||
id: current
|
||||
run: |
|
||||
CURRENT=$(find . -path "*/app/manifest.json" -exec jq -r '.acapPackageConf.setup.version' {} \; | sort -u | head -n1)
|
||||
echo "CURRENT_VERSION=$CURRENT" >> $GITHUB_ENV
|
||||
echo "Current repo version: $CURRENT"
|
||||
|
||||
- name: Compare versions
|
||||
id: compare
|
||||
run: |
|
||||
echo "Repo version: $CURRENT_VERSION"
|
||||
echo "Latest Tailscale version: $RELEASE_VERSION"
|
||||
echo "Trigger: ${{ github.event_name }}"
|
||||
|
||||
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 "Already up to date. Skipping build."
|
||||
else
|
||||
echo "build_needed=true" >> $GITHUB_ENV
|
||||
echo "New version detected. Will build."
|
||||
fi
|
||||
|
||||
# 3. Download Tailscale binaries
|
||||
- name: Download Tailscale binaries
|
||||
if: env.build_needed == 'true'
|
||||
run: |
|
||||
mkdir -p tailscale_bins
|
||||
curl -L "https://pkgs.tailscale.com/stable/tailscale_${RELEASE_VERSION}_arm.tgz" -o tailscale_arm.tgz
|
||||
tar -xzf tailscale_arm.tgz -C tailscale_bins --strip-components=1
|
||||
mv tailscale_bins/tailscale tailscale_bins/tailscale_arm
|
||||
mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm
|
||||
|
||||
curl -L "https://pkgs.tailscale.com/stable/tailscale_${RELEASE_VERSION}_arm64.tgz" -o tailscale_arm64.tgz
|
||||
tar -xzf tailscale_arm64.tgz -C tailscale_bins --strip-components=1
|
||||
mv tailscale_bins/tailscale tailscale_bins/tailscale_arm64
|
||||
mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm64
|
||||
|
||||
# 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
|
||||
if: env.build_needed == 'true'
|
||||
run: |
|
||||
mkdir -p build
|
||||
rm -rf releases
|
||||
mkdir -p releases
|
||||
|
||||
for folder in */ ; do
|
||||
[[ ! -d "$folder/app" ]] && continue
|
||||
FOLDER_NAME="${folder%/}" # remove trailing slash
|
||||
echo "Processing folder $FOLDER_NAME"
|
||||
|
||||
# Detect architecture
|
||||
if [[ "$FOLDER_NAME" == arm* ]]; then
|
||||
cp tailscale_bins/tailscale_arm "$folder/app/lib/tailscale"
|
||||
cp tailscale_bins/tailscaled_arm "$folder/app/lib/tailscaled"
|
||||
else
|
||||
cp tailscale_bins/tailscale_arm64 "$folder/app/lib/tailscale"
|
||||
cp tailscale_bins/tailscaled_arm64 "$folder/app/lib/tailscaled"
|
||||
fi
|
||||
|
||||
# Detect variant suffix for .eap naming
|
||||
if [[ "$FOLDER_NAME" == *_ROOT ]]; then
|
||||
VARIANT="_root"
|
||||
elif [[ "$FOLDER_NAME" == *_custom ]]; then
|
||||
VARIANT="_custom"
|
||||
else
|
||||
VARIANT=""
|
||||
fi
|
||||
|
||||
# Update manifest version
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"${RELEASE_VERSION}\"/" "$folder/app/manifest.json"
|
||||
|
||||
# Docker build
|
||||
TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean
|
||||
echo "Building $TAG_NAME"
|
||||
docker build -f "$folder/Dockerfile" --tag "$TAG_NAME" "$folder"
|
||||
|
||||
# Extract .eap files into build folder
|
||||
EAP_OUTPUT="./build/${TAG_NAME}"
|
||||
mkdir -p "$EAP_OUTPUT"
|
||||
docker cp $(docker create "$TAG_NAME"):/opt/app "$EAP_OUTPUT"
|
||||
|
||||
# Move all .eap files to releases folder, append variant if needed
|
||||
find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do
|
||||
BASENAME=$(basename "$eap" .eap)
|
||||
if [[ -n "$VARIANT" ]]; then
|
||||
mv "$eap" "releases/${BASENAME}${VARIANT}.eap"
|
||||
else
|
||||
mv "$eap" "releases/${BASENAME}.eap"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Clean up
|
||||
rm -rf build tailscale_bins *.tgz
|
||||
|
||||
# 6. Commit updated manifests and .eap files directly to main
|
||||
- name: Commit updates to main
|
||||
if: env.build_needed == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
# Only commit manifests; do not track release artifacts
|
||||
git add */app/manifest.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "Update Tailscale to v${RELEASE_VERSION}"
|
||||
git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/Mo3he/Axis_Cam_Tailscale.git main
|
||||
fi
|
||||
|
||||
# 7. Create GitHub Release with all new .eap files
|
||||
- name: Create GitHub Release
|
||||
if: env.build_needed == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ env.RELEASE_VERSION }}
|
||||
name: "Tailscale VPN ${{ env.RELEASE_VERSION }}"
|
||||
files: releases/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+12
@@ -1,2 +1,14 @@
|
||||
**/.DS_Store
|
||||
**/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
|
||||
|
||||
@@ -1,94 +1,149 @@
|
||||
# The Tailscale installer ACAP
|
||||
# Tailscale ACAP for Axis Cameras
|
||||
|
||||
This ACAP packages the scripts and files required to install the Tailscale VPN client on Axis Cameras.
|
||||
🌐 **[Visit the Homepage](https://mo3he.github.io/Axis_Cam_Tailscale/)**
|
||||
|
||||
Current version 1.60.0
|
||||
There is also now a version called "Auto Update" that will automatically keep tailscale up to date.
|
||||
This repository provides an **ACAP package** that installs the [Tailscale VPN client](https://tailscale.com/) on Axis cameras.
|
||||
|
||||
https://tailscale.com/changelog/
|
||||
- ✅ Secure remote access to cameras
|
||||
- ✅ Easy to install via EAP package
|
||||
- ✅ Works on **Axis OS 12+** (non-root version)
|
||||
- ✅ Based on **WireGuard VPN** technology
|
||||
|
||||
## Warning
|
||||
Unfortunately Axis is making changes to its firmware that will have breaking changes for a lot of ACAPs including my own and as of today there is no way to ready my ACAPs for these changes.
|
||||
|
||||
You can read more here
|
||||
|
||||
https://help.axis.com/en-us/axis-os#upcoming-breaking-changes
|
||||
[](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
|
||||
[](LICENSE)
|
||||

|
||||
[](https://github.com/sponsors/Mo3he)
|
||||
|
||||
Please also remember that these ACAPS are in no way affiliated with or authorized by Axis and come with no warranty or support, they are provided as is and while I hope they will continue working in the future there is no guarantee.
|
||||
### Disclaimer: This is an independent, community-developed ACAP package and is not an official Axis Communications product. It was developed entirely on personal time and 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
|
||||
|
||||
Thank you for your continued support.
|
||||
> **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).
|
||||
---
|
||||
|
||||
If you have a use case where certain functionality used by an ACAP application currently requires root-user permissions or have a question about ACAP application signing, please contact Axis at acap-privileges@axis.com
|
||||
## 📑 Table of Contents
|
||||
|
||||
## Purpose
|
||||
- [📥 Installation](#-installation)
|
||||
- [🚀 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)
|
||||
|
||||
Adding a VPN client directly to the camera allows secure remote access to the device without requiring any other equipment or network configuration.
|
||||
Tailscale achieves this in a secure, simple to setup and easy to use way.
|
||||
Tailscale is based on WireGuard VPN tunneling technology.
|
||||
---
|
||||
|
||||
https://tailscale.com/blog/how-tailscale-works/
|
||||
## 📥 Installation
|
||||
|
||||
## Links
|
||||
Get the **prebuilt `.eap` file** from the [Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
|
||||
|
||||
https://tailscale.com/
|
||||
1. Log into your Axis camera.
|
||||
2. Go to **Apps → Add App**.
|
||||
3. Upload the `.eap` file.
|
||||
|
||||
https://github.com/tailscale/tailscale
|
||||
Once installed:
|
||||
- Start the app.
|
||||
- Click **Open** to view logs and get your Tailscale authentication URL.
|
||||
- On uninstall, all changes/files are removed.
|
||||
|
||||
https://www.wireguard.com/
|
||||
> ⚠️ You’ll need a [Tailscale account](https://tailscale.com/) to authenticate.
|
||||
|
||||
https://www.axis.com/
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
## 🚀 Usage
|
||||
|
||||
The Tailscale ACAP is compatable with Axis cameras with arm and aarch64 based Soc's.
|
||||
- Runs a startup script to set permissions and launch Tailscale.
|
||||
- View logs via the **Open** button in the app.
|
||||
- Authenticate using the provided URL.
|
||||
|
||||
```
|
||||
curl --anyauth "*" -u <username>:<password> <device ip>/axis-cgi/basicdeviceinfo.cgi --data "{\"apiVersion\":\"1.0\",\"context\":\"Client defined request ID\",\"method\":\"getAllProperties\"}"
|
||||
---
|
||||
|
||||
## 🔄 Updating Tailscale
|
||||
|
||||
- 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.
|
||||
|
||||
### Manual update (advanced)
|
||||
|
||||
Replace the binaries in the `lib/` folder:
|
||||
- `tailscale`
|
||||
- `tailscaled`
|
||||
|
||||
Download the latest versions: [Tailscale static builds](https://pkgs.tailscale.com/stable/#static)
|
||||
|
||||
#### Build locally
|
||||
|
||||
From the main directory of the version you want (`arm` / `aarch64`):
|
||||
|
||||
```bash
|
||||
docker build --tag <package_name> .
|
||||
docker cp $(docker create <package_name>):/opt/app ./build
|
||||
```
|
||||
|
||||
where `<device ip>` is the IP address of the Axis device, `<username>` is the root username and `<password>` is the root password. Please
|
||||
note that you need to enclose your password with quotes (`'`) if it contains special characters.
|
||||
---
|
||||
|
||||
## Installing
|
||||
## 🧪 Testers Needed
|
||||
|
||||
The recommended way to install this ACAP is to use the pre built eap file.
|
||||
Go to "Apps" on the camera and click "Add app".
|
||||
A new **custom** version is available:
|
||||
- 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!
|
||||
|
||||
## Using the Tailscale ACAP
|
||||
---
|
||||
|
||||
The Tailscale ACAP will run a script on startup that sets the required permissions and starts the service and app.
|
||||
Once started click "Open" to see the output of the logs for further instructions and obtain the authetication URL.
|
||||
## 🎉 Good News
|
||||
|
||||
When uninstalling the ACAP, all changes and files are removed from the camera.
|
||||
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 12+**.
|
||||
|
||||
You will need a tailscale.com account to use the ACAP
|
||||
- Runs in **user space networking mode**.
|
||||
|
||||
## Updating Tailscale version
|
||||
For **full networking features**, use the **ROOT** version on Axis OS < 12.
|
||||
|
||||
The eap files will be updated from time to time and simply installing the new version over the old will update all files.
|
||||
---
|
||||
|
||||
It's also possible to build and use a locally built image as all necesary files are provided.
|
||||
## 🎯 Purpose
|
||||
|
||||
Replace binaries "tailscale" and "tailscaled" in lib folder with new versions.
|
||||
Make sure you use the files for the correct Soc.
|
||||
Adding a VPN client directly to the camera enables:
|
||||
- Secure remote access without additional hardware or complex network configuration.
|
||||
- Easy setup through Tailscale’s lightweight WireGuard-based tunnel.
|
||||
|
||||
Latest versions can be found at
|
||||
🔗 Learn more: [How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/)
|
||||
|
||||
https://pkgs.tailscale.com/stable/#static
|
||||
---
|
||||
|
||||
## 🔗 Useful Links
|
||||
|
||||
To build,
|
||||
From main directory of the version you want (arm/aarch64)
|
||||
- [Tailscale](https://tailscale.com/)
|
||||
- [Tailscale GitHub](https://github.com/tailscale/tailscale)
|
||||
- [WireGuard](https://www.wireguard.com/)
|
||||
- [Axis Communications](https://www.axis.com/)
|
||||
|
||||
```
|
||||
docker build --tag <package name> .
|
||||
```
|
||||
```
|
||||
docker cp $(docker create <package name>):/opt/app ./build
|
||||
---
|
||||
|
||||
## 🖥️ Compatibility
|
||||
|
||||
The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**-based SoCs.
|
||||
|
||||
You can verify your device details using the following command:
|
||||
|
||||
```bash
|
||||
curl --anyauth "*" -u <username>:<password> <device_ip>/axis-cgi/basicdeviceinfo.cgi --data '{"apiVersion":"1.0","context":"Client defined request ID","method":"getAllProperties"}'
|
||||
```
|
||||
|
||||
> Replace `<device_ip>`, `<username>`, and `<password>` with your device credentials.
|
||||
> Enclose your password in quotes `' '` if it contains special characters.
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date)
|
||||
|
||||
---
|
||||
|
||||
## 💖 Support
|
||||
|
||||
If you like this project and want to support my work:
|
||||
👉 [Sponsor Me](https://github.com/sponsors/Mo3he)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Starting Service"
|
||||
chmod 777 /usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscale
|
||||
chmod 777 /usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscaled
|
||||
/usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscaled &
|
||||
echo "Service Started"
|
||||
echo "Scroll to Bottom for link"
|
||||
/usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscale up
|
||||
/usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscale set --auto-update
|
||||
wait
|
||||
@@ -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=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN_Auto_Update",
|
||||
"friendlyName": "Tailscale VPN Auto Update",
|
||||
"vendor": "Tailscale - Packaged by Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://www.tailscale.com",
|
||||
"runMode": "once",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,31 @@
|
||||
#!/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
|
||||
wait
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting Tailscale VPN service"
|
||||
|
||||
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 (userspace networking)"
|
||||
"$APP_DIR/lib/tailscaled" \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
--socks5-server=localhost:1055 \
|
||||
--tun=userspace-networking \
|
||||
2>&1 | logger -t "Tailscale_VPN" &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
|
||||
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up 2>&1 | logger -t "Tailscale_VPN"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
wait $TAILSCALED_PID
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
|
||||
+672
-13
@@ -1,19 +1,678 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<html lang="en">
|
||||
<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);
|
||||
<meta charset="UTF-8">
|
||||
<title>Tailscale VPN</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #181b23;
|
||||
--surface2: #1e2230;
|
||||
--border: #262a35;
|
||||
--text: #e4e6ed;
|
||||
--muted: #8b8fa3;
|
||||
--accent: #4f8ff7;
|
||||
--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: #2563eb;
|
||||
}
|
||||
|
||||
* { 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(79,143,247,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(79,143,247,0.1);
|
||||
border: 1px solid rgba(79,143,247,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 onload="refreshIFrame()">
|
||||
<iframe id="*Your_iframe_id*" src= "/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
|
||||
</body>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
|
||||
<rect width="128" height="128" rx="28" fill="#0166FF"/>
|
||||
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="32" cy="64" r="13" fill="white"/>
|
||||
<circle cx="64" cy="64" r="13" fill="white"/>
|
||||
<circle cx="96" cy="64" r="13" fill="white"/>
|
||||
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="96" r="13" fill="white"/>
|
||||
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
</svg>
|
||||
<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;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
if (!node) {
|
||||
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]"
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[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];
|
||||
}
|
||||
}
|
||||
|
||||
// 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) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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; });
|
||||
}
|
||||
|
||||
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);
|
||||
if (result.state !== 'disconnected') {
|
||||
render(result);
|
||||
} else {
|
||||
// Syslog may be empty/rotated - check app status API
|
||||
checkAppRunning().then(function(running) {
|
||||
if (running) {
|
||||
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>
|
||||
</html>
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
+15
-18
@@ -1,21 +1,18 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Tailscale - Packaged by Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://www.tailscale.com",
|
||||
"runMode": "once",
|
||||
"version": "1.60.0"
|
||||
"schemaVersion": "1.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4",
|
||||
"architecture": "aarch64"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting Tailscale VPN service (root mode)"
|
||||
|
||||
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"
|
||||
"$APP_DIR/lib/tailscaled" \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
2>&1 | logger -t "Tailscale_VPN" &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
|
||||
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --accept-routes 2>&1 | logger -t "Tailscale_VPN"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
wait $TAILSCALED_PID
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
@@ -0,0 +1,677 @@
|
||||
<!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: #4f8ff7;
|
||||
--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: #2563eb;
|
||||
}
|
||||
|
||||
* { 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(79,143,247,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(79,143,247,0.1);
|
||||
border: 1px solid rgba(79,143,247,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">
|
||||
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
|
||||
<rect width="128" height="128" rx="28" fill="#0166FF"/>
|
||||
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="32" cy="64" r="13" fill="white"/>
|
||||
<circle cx="64" cy="64" r="13" fill="white"/>
|
||||
<circle cx="96" cy="64" r="13" fill="white"/>
|
||||
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="96" r="13" fill="white"/>
|
||||
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
</svg>
|
||||
<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;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
if (!node) {
|
||||
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]"
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[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];
|
||||
}
|
||||
}
|
||||
|
||||
// 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) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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; });
|
||||
}
|
||||
|
||||
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);
|
||||
if (result.state !== 'disconnected') {
|
||||
render(result);
|
||||
} else {
|
||||
checkAppRunning().then(function(running) {
|
||||
if (running) {
|
||||
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>
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4",
|
||||
"architecture": "aarch64"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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 .
|
||||
@@ -0,0 +1,5 @@
|
||||
To build, from main directory
|
||||
|
||||
docker build --tag aarch64 .
|
||||
|
||||
docker cp $(docker create aarch64):/opt/app ./build
|
||||
@@ -0,0 +1,29 @@
|
||||
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*
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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 APP_DIR "/usr/local/packages/serverconfig"
|
||||
#define STATE_DIR APP_DIR "/localdata"
|
||||
#define CONFIG_FILE STATE_DIR "/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;
|
||||
}
|
||||
|
||||
// Create localdata directory
|
||||
static void ensure_localdata_exists(void) {
|
||||
struct stat st = {0};
|
||||
|
||||
if (stat(STATE_DIR, &st) == -1) {
|
||||
if (mkdir(STATE_DIR, 0755) != 0) {
|
||||
syslog(LOG_ERR, "Failed to create localdata directory: %s", strerror(errno));
|
||||
} else {
|
||||
syslog(LOG_INFO, "Created localdata directory: %s", STATE_DIR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Ensure localdata directory exists
|
||||
ensure_localdata_exists();
|
||||
|
||||
// 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 in localdata
|
||||
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 in local custom_server=%s",
|
||||
server_value ? server_value : "");
|
||||
syslog(LOG_INFO, "Updated configuration file in local auth_key=%s",
|
||||
key_value && strlen(key_value) > 0 ? "(set)" : "(empty)");
|
||||
} else {
|
||||
syslog(LOG_ERR, "Failed to open config file for writing: %s", strerror(errno));
|
||||
}
|
||||
|
||||
// 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 localdata directory exists
|
||||
ensure_localdata_exists();
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
<!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: #4f8ff7;
|
||||
--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: #2563eb;
|
||||
}
|
||||
|
||||
* { 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(79,143,247,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(79,143,247,0.1);
|
||||
border: 1px solid rgba(79,143,247,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">
|
||||
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
|
||||
<rect width="128" height="128" rx="28" fill="#0166FF"/>
|
||||
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="32" cy="64" r="13" fill="white"/>
|
||||
<circle cx="64" cy="64" r="13" fill="white"/>
|
||||
<circle cx="96" cy="64" r="13" fill="white"/>
|
||||
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="96" r="13" fill="white"/>
|
||||
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
</svg>
|
||||
<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 = 'serverconfig';
|
||||
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;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
if (!node) {
|
||||
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]"
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[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];
|
||||
}
|
||||
}
|
||||
|
||||
// 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) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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; });
|
||||
}
|
||||
|
||||
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);
|
||||
if (result.state !== 'disconnected') {
|
||||
render(result);
|
||||
} else {
|
||||
checkAppRunning().then(function(running) {
|
||||
if (running) {
|
||||
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>
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/bin/sh
|
||||
# Make sure this script terminates any existing Tailscale processes before starting new ones
|
||||
|
||||
# Kill any existing tailscaled processes
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
# Simple script to start Tailscale with custom configuration
|
||||
APP_DIR="/usr/local/packages/serverconfig"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
CONFIG_FILE="$STATE_DIR/config.txt"
|
||||
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
|
||||
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
|
||||
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
|
||||
|
||||
# Create localdata directory if it doesn't exist
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# 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 with state stored in localdata
|
||||
logger -t "tailscale_script" "Starting tailscaled daemon"
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
--socks5-server=localhost:1055 \
|
||||
--tun=userspace-networking \
|
||||
2>&1 | logger -t "tailscale_script" &
|
||||
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
|
||||
UP_EXIT=$?
|
||||
|
||||
# Clear auth key from config after first use — Tailscale auth keys are single-use
|
||||
# and the node identity is persisted in tailscaled.state, so the key is no longer needed.
|
||||
if [ -n "$AUTH_KEY" ] && [ "$UP_EXIT" -eq 0 ] && [ -f "$CONFIG_FILE" ]; then
|
||||
logger -t "tailscale_script" "Clearing auth key from config after successful authentication"
|
||||
printf 'custom_server=%s\nauth_key=\n' "$CUSTOM_SERVER" > "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# 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
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"schemaVersion": "1.7.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"appName": "serverconfig",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html",
|
||||
"paramConfig": [
|
||||
{
|
||||
"name": "CustomServer",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AuthKey",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Starting Service"
|
||||
chmod 777 /usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscale
|
||||
chmod 777 /usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscaled
|
||||
/usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscaled &
|
||||
echo "Service Started"
|
||||
echo "Scroll to Bottom for link"
|
||||
/usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscale up
|
||||
/usr/local/packages/Tailscale_VPN_Auto_Update/lib/tailscale set --auto-update
|
||||
wait
|
||||
@@ -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=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN_Auto_Update",
|
||||
"friendlyName": "Tailscale VPN Auto Update",
|
||||
"vendor": "Tailscale - Packaged by Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://www.tailscale.com",
|
||||
"runMode": "once",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-8
@@ -1,10 +1,31 @@
|
||||
#!/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
|
||||
wait
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting Tailscale VPN service"
|
||||
|
||||
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 (userspace networking)"
|
||||
"$APP_DIR/lib/tailscaled" \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
--socks5-server=localhost:1055 \
|
||||
--tun=userspace-networking \
|
||||
2>&1 | logger -t "Tailscale_VPN" &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
|
||||
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up 2>&1 | logger -t "Tailscale_VPN"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
wait $TAILSCALED_PID
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
|
||||
+671
-13
@@ -1,19 +1,677 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<html lang="en">
|
||||
<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);
|
||||
<meta charset="UTF-8">
|
||||
<title>Tailscale VPN</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #181b23;
|
||||
--surface2: #1e2230;
|
||||
--border: #262a35;
|
||||
--text: #e4e6ed;
|
||||
--muted: #8b8fa3;
|
||||
--accent: #4f8ff7;
|
||||
--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: #2563eb;
|
||||
}
|
||||
|
||||
* { 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(79,143,247,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(79,143,247,0.1);
|
||||
border: 1px solid rgba(79,143,247,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 onload="refreshIFrame()">
|
||||
<iframe id="*Your_iframe_id*" src= "/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
|
||||
</body>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
|
||||
<rect width="128" height="128" rx="28" fill="#0166FF"/>
|
||||
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="32" cy="64" r="13" fill="white"/>
|
||||
<circle cx="64" cy="64" r="13" fill="white"/>
|
||||
<circle cx="96" cy="64" r="13" fill="white"/>
|
||||
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="96" r="13" fill="white"/>
|
||||
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
</svg>
|
||||
<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;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
if (!node) {
|
||||
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]"
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[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];
|
||||
}
|
||||
}
|
||||
|
||||
// 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) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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; });
|
||||
}
|
||||
|
||||
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);
|
||||
if (result.state !== 'disconnected') {
|
||||
render(result);
|
||||
} else {
|
||||
checkAppRunning().then(function(running) {
|
||||
if (running) {
|
||||
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>
|
||||
</html>
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
+15
-18
@@ -1,21 +1,18 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Tailscale - Packaged by Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://www.tailscale.com",
|
||||
"runMode": "once",
|
||||
"version": "1.60.0"
|
||||
"schemaVersion": "1.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4",
|
||||
"architecture": "armv7hf"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting Tailscale VPN service (root mode)"
|
||||
|
||||
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"
|
||||
"$APP_DIR/lib/tailscaled" \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
2>&1 | logger -t "Tailscale_VPN" &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
logger -t "Tailscale_VPN" "Connecting to Tailscale network (scroll to bottom for auth URL if prompted)"
|
||||
"$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" up --accept-routes 2>&1 | logger -t "Tailscale_VPN"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
wait $TAILSCALED_PID
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
@@ -0,0 +1,677 @@
|
||||
<!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: #4f8ff7;
|
||||
--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: #2563eb;
|
||||
}
|
||||
|
||||
* { 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(79,143,247,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(79,143,247,0.1);
|
||||
border: 1px solid rgba(79,143,247,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">
|
||||
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
|
||||
<rect width="128" height="128" rx="28" fill="#0166FF"/>
|
||||
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="32" cy="64" r="13" fill="white"/>
|
||||
<circle cx="64" cy="64" r="13" fill="white"/>
|
||||
<circle cx="96" cy="64" r="13" fill="white"/>
|
||||
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="96" r="13" fill="white"/>
|
||||
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
</svg>
|
||||
<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;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
if (!node) {
|
||||
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]"
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[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];
|
||||
}
|
||||
}
|
||||
|
||||
// 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) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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; });
|
||||
}
|
||||
|
||||
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);
|
||||
if (result.state !== 'disconnected') {
|
||||
render(result);
|
||||
} else {
|
||||
checkAppRunning().then(function(running) {
|
||||
if (running) {
|
||||
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>
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4",
|
||||
"architecture": "armv7hf"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
ARG ARCH=armv7hf
|
||||
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 .
|
||||
@@ -0,0 +1,29 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,29 @@
|
||||
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*
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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 APP_DIR "/usr/local/packages/serverconfig"
|
||||
#define STATE_DIR APP_DIR "/localdata"
|
||||
#define CONFIG_FILE STATE_DIR "/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;
|
||||
}
|
||||
|
||||
// Create localdata directory
|
||||
static void ensure_localdata_exists(void) {
|
||||
struct stat st = {0};
|
||||
|
||||
if (stat(STATE_DIR, &st) == -1) {
|
||||
if (mkdir(STATE_DIR, 0755) != 0) {
|
||||
syslog(LOG_ERR, "Failed to create localdata directory: %s", strerror(errno));
|
||||
} else {
|
||||
syslog(LOG_INFO, "Created localdata directory: %s", STATE_DIR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Ensure localdata directory exists
|
||||
ensure_localdata_exists();
|
||||
|
||||
// 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 in localdata
|
||||
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 in local custom_server=%s",
|
||||
server_value ? server_value : "");
|
||||
syslog(LOG_INFO, "Updated configuration file in local auth_key=%s",
|
||||
key_value && strlen(key_value) > 0 ? "(set)" : "(empty)");
|
||||
} else {
|
||||
syslog(LOG_ERR, "Failed to open config file for writing: %s", strerror(errno));
|
||||
}
|
||||
|
||||
// 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 localdata directory exists
|
||||
ensure_localdata_exists();
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
<!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: #4f8ff7;
|
||||
--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: #2563eb;
|
||||
}
|
||||
|
||||
* { 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(79,143,247,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(79,143,247,0.1);
|
||||
border: 1px solid rgba(79,143,247,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">
|
||||
<svg width="26" height="26" viewBox="0 0 128 128" fill="none">
|
||||
<rect width="128" height="128" rx="28" fill="#0166FF"/>
|
||||
<circle cx="32" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="96" cy="32" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="32" cy="64" r="13" fill="white"/>
|
||||
<circle cx="64" cy="64" r="13" fill="white"/>
|
||||
<circle cx="96" cy="64" r="13" fill="white"/>
|
||||
<circle cx="32" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
<circle cx="64" cy="96" r="13" fill="white"/>
|
||||
<circle cx="96" cy="96" r="13" fill="white" opacity="0.4"/>
|
||||
</svg>
|
||||
<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 = 'serverconfig';
|
||||
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;
|
||||
}
|
||||
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
var node = null;
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
if (!node) {
|
||||
// Extract hostname from Axis syslog format: "2026-01-01T...+00:00 Hostname [ NOTICE ]"
|
||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||
if (hostLine) node = hostLine[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];
|
||||
}
|
||||
}
|
||||
|
||||
// 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) || /Tailscale VPN is running/.test(txt);
|
||||
// Detect running even after syslog rotation (daemon active but startup lines gone)
|
||||
if (!isRunning && !latestUrl) {
|
||||
isRunning = /health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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; });
|
||||
}
|
||||
|
||||
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);
|
||||
if (result.state !== 'disconnected') {
|
||||
render(result);
|
||||
} else {
|
||||
checkAppRunning().then(function(running) {
|
||||
if (running) {
|
||||
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>
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/bin/sh
|
||||
# Make sure this script terminates any existing Tailscale processes before starting new ones
|
||||
|
||||
# Kill any existing tailscaled processes
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
# Simple script to start Tailscale with custom configuration
|
||||
APP_DIR="/usr/local/packages/serverconfig"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
CONFIG_FILE="$STATE_DIR/config.txt"
|
||||
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
|
||||
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
|
||||
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
|
||||
|
||||
# Create localdata directory if it doesn't exist
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# 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 with state stored in localdata
|
||||
logger -t "tailscale_script" "Starting tailscaled daemon"
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
--socks5-server=localhost:1055 \
|
||||
--tun=userspace-networking \
|
||||
2>&1 | logger -t "tailscale_script" &
|
||||
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
|
||||
UP_EXIT=$?
|
||||
|
||||
# Clear auth key from config after first use — Tailscale auth keys are single-use
|
||||
# and the node identity is persisted in tailscaled.state, so the key is no longer needed.
|
||||
if [ -n "$AUTH_KEY" ] && [ "$UP_EXIT" -eq 0 ] && [ -f "$CONFIG_FILE" ]; then
|
||||
logger -t "tailscale_script" "Clearing auth key from config after successful authentication"
|
||||
printf 'custom_server=%s\nauth_key=\n' "$CUSTOM_SERVER" > "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": "1.7.3",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"appName": "serverconfig",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.96.4",
|
||||
"architecture": "armv7hf"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html",
|
||||
"paramConfig": [
|
||||
{
|
||||
"name": "CustomServer",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "AuthKey",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+574
@@ -0,0 +1,574 @@
|
||||
<!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 Cameras</title>
|
||||
<meta name="description" content="Install Tailscale VPN directly on your Axis camera. 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 Cameras">
|
||||
<meta property="og:description" content="Secure remote access to your Axis cameras 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 Cameras">
|
||||
<meta name="twitter:description" content="Secure remote access to your Axis cameras 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 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-custom { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
|
||||
.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 · ACAP Package</div>
|
||||
<h1>Tailscale VPN for <span>Axis Cameras</span></h1>
|
||||
<p>Secure remote access to your Axis cameras 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 was developed entirely on personal time and 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 camera.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">📦</div>
|
||||
<h3>Simple EAP Install</h3>
|
||||
<p>Upload the .eap file through your camera'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>The Custom variant supports self-hosted Headscale servers with configurable server URL and auth key.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🏗️</div>
|
||||
<h3>ARM & AARCH64</h3>
|
||||
<p>Supports both ARM (armv7hf) and AARCH64 architectures, covering a wide range of Axis camera models.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Downloads -->
|
||||
<section id="downloads" class="downloads">
|
||||
<h2>Download</h2>
|
||||
<p class="subtitle">Pick the right variant for your camera 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>
|
||||
<!-- Custom -->
|
||||
<div class="download-card">
|
||||
<div class="tag tag-custom">Custom / Headscale</div>
|
||||
<h3>Custom Server</h3>
|
||||
<p>Supports custom Tailscale control servers and auth keys. Ideal for self-hosted Headscale setups.</p>
|
||||
<div class="arch-buttons">
|
||||
<a class="arch-btn" data-asset="custom" 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="custom" 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 · 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>
|
||||
</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 camera 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 camera</strong>
|
||||
<span>Log into your Axis camera web interface and go to <strong>Apps → 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 & 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://tailscale.com/" target="_blank" rel="noopener">Tailscale</a>
|
||||
<a href="https://www.axis.com/" target="_blank" rel="noopener">Axis</a>
|
||||
</div>
|
||||
<p>© 2025 Mo3he · 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');
|
||||
}
|
||||
});
|
||||
|
||||
// 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('custom') === -1 && name.indexOf('root') === -1;
|
||||
} else if (type === 'custom') {
|
||||
typeMatch = name.indexOf('custom') !== -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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
Reference in New Issue
Block a user