Compare commits

..
1 Commits
73 changed files with 4507 additions and 2752 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"app": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"versionPolicy": "mirror",
"upstream": {
"type": "script",
"script": "ci/upstream-version.sh",
"name": "Tailscale",
"changesUrl": "https://tailscale.com/changelog"
},
"build": {
"command": "./build.sh",
"env": {}
},
"pins": [],
"signing": {
"skip": [
"*_acap3.eap",
"*_root.eap"
]
}
}
-102
View File
@@ -1,102 +0,0 @@
---
Language: Cpp
# BasedOnStyle: LLVM
AccessModifierOffset: -2
AlignAfterOpenBracket: AlwaysBreak
AlignConsecutiveAssignments: false
AlignConsecutiveDeclarations: false
AlignEscapedNewlines: Right
AlignOperands: true
AlignTrailingComments: true
AllowAllParametersOfDeclarationOnNextLine: false
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AlwaysBreakAfterDefinitionReturnType: None
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: false
AlwaysBreakTemplateDeclarations: MultiLine
BinPackArguments: false
BinPackParameters: false
BreakBeforeBinaryOperators: None
BreakBeforeBraces: Attach
BreakBeforeInheritanceComma: false
BreakInheritanceList: BeforeColon
BreakBeforeTernaryOperators: true
BreakConstructorInitializersBeforeComma: false
BreakConstructorInitializers: BeforeColon
BreakAfterJavaFieldAnnotations: false
BreakStringLiterals: true
ColumnLimit: 120
CommentPragmas: '^ IWYU pragma:'
CompactNamespaces: false
ConstructorInitializerAllOnOneLineOrOnePerLine: false
ConstructorInitializerIndentWidth: 4
ContinuationIndentWidth: 4
Cpp11BracedListStyle: true
DerivePointerAlignment: false
DisableFormat: false
ExperimentalAutoDetectBinPacking: false
FixNamespaceComments: true
ForEachMacros:
- foreach
- Q_FOREACH
- BOOST_FOREACH
IncludeBlocks: Preserve
IncludeCategories:
- Regex: '^"(llvm|llvm-c|clang|clang-c)/'
Priority: 2
- Regex: '^(<|"(gtest|gmock|isl|json)/)'
Priority: 3
- Regex: '.*'
Priority: 1
IncludeIsMainRegex: '(Test)?$'
IndentCaseLabels: false
IndentPPDirectives: None
IndentWidth: 4
IndentWrappedFunctionNames: false
JavaScriptQuotes: Leave
JavaScriptWrapImports: true
KeepEmptyLinesAtTheStartOfBlocks: true
MacroBlockBegin: ''
MacroBlockEnd: ''
MaxEmptyLinesToKeep: 1
NamespaceIndentation: None
ObjCBinPackProtocolList: Auto
ObjCBlockIndentWidth: 2
ObjCSpaceAfterProperty: false
ObjCSpaceBeforeProtocolList: true
PenaltyBreakAssignment: 2
PenaltyBreakBeforeFirstCallParameter: 19
PenaltyBreakComment: 300
PenaltyBreakFirstLessLess: 120
PenaltyBreakString: 1000
PenaltyBreakTemplateDeclaration: 10
PenaltyExcessCharacter: 1000000
PenaltyReturnTypeOnItsOwnLine: 60
PointerAlignment: Right
ReflowComments: true
SortIncludes: true
SortUsingDeclarations: true
SpaceAfterCStyleCast: false
SpaceAfterTemplateKeyword: true
SpaceBeforeAssignmentOperators: true
SpaceBeforeCpp11BracedList: false
SpaceBeforeCtorInitializerColon: true
SpaceBeforeInheritanceColon: true
SpaceBeforeParens: ControlStatements
SpaceBeforeRangeBasedForLoopColon: true
SpaceInEmptyParentheses: false
SpacesBeforeTrailingComments: 1
SpacesInAngles: false
SpacesInContainerLiterals: true
SpacesInCStyleCastParentheses: false
SpacesInParentheses: false
SpacesInSquareBrackets: false
Standard: Cpp11
TabWidth: 8
UseTab: Never
...
-12
View File
@@ -1,12 +0,0 @@
# All variants build from the repository root (docker build -f <variant>/Dockerfile .)
# so keep the context lean. Do NOT exclude common/app/ or <variant>/app/ — the
# Dockerfiles COPY those, including the Tailscale binaries placed in app/lib/.
.git
.github
.DS_Store
*.eap
*.tgz
build
releases
tailscale_bins
README.md
-17
View File
@@ -1,17 +0,0 @@
---
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
# One PR per repo instead of one per action; the workflow is generated from
# Axis_Cam_Template/ci/build.yml.tmpl, so apply bumps there and re-sync.
groups:
github-actions:
patterns:
- "*"
commit-message:
prefix: ci
labels:
- dependencies
-11
View File
@@ -1,11 +0,0 @@
DEFAULT_BRANCH=origin/main
LINTER_RULES_PATH=/
VALIDATE_ALL_CODEBASE=true
IGNORE_GITIGNORED_FILES=true
YAML_CONFIG_FILE=.yamllint.yaml
MARKDOWN_CONFIG_FILE=.markdownlint.yaml
VALIDATE_DOCKERFILE_HADOLINT=true
VALIDATE_JSON=true
VALIDATE_MARKDOWN=true
VALIDATE_SHELL_SHFMT=true
VALIDATE_YAML=true
+175 -139
View File
@@ -1,159 +1,195 @@
---
# GENERATED by acap-ci.sh from Axis_Cam_Template/ci/build.yml.tmpl
# Per-repo settings live in .acap.json. Do not edit this file directly.
#
# Upstream release -> build -> DRAFT release holding unsigned .eap files.
# Signing is manual (Axis has no signing API); ../acap-sign.sh uploads the
# signed packages and publishes the release. The acap-ops repo notifies.
name: Build
name: Auto Build & Release Tailscale ACAP
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 3 * * *"
- cron: "0 0 * * *" # Every Monday at 03:00 UTC
workflow_dispatch:
inputs:
version:
description: "Version to build. Empty resolves from upstream."
required: false
force:
description: "Rebuild and re-cut the draft even if unchanged."
type: boolean
default: false
permissions:
contents: write
concurrency:
group: acap-release-${{ github.ref }}
cancel-in-progress: false
jobs:
check:
build-and-release:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.decide.outputs.build }}
release: ${{ steps.decide.outputs.release }}
version: ${{ steps.decide.outputs.version }}
upstream: ${{ steps.decide.outputs.upstream }}
steps:
- uses: actions/checkout@v7
- id: decide
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_FORCE: ${{ github.event.inputs.force }}
EVENT_NAME: ${{ github.event_name }}
run: ./ci/resolve-version.sh
build:
needs: check
if: needs.check.outputs.build == 'true'
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.check.outputs.version }}
UPSTREAM_VERSION: ${{ needs.check.outputs.upstream }}
steps:
- uses: actions/checkout@v7
# 1. Checkout repo
- uses: actions/checkout@v3
with:
persist-credentials: true
fetch-depth: 0
- name: Apply version and upstream pins
run: ./ci/apply-version.sh "$VERSION" "$UPSTREAM_VERSION"
# 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}
- name: Build packages
run: ./ci/build-packages.sh
echo "GitHub latest: $GH_VERSION"
- name: Verify packages
run: |
set -euo pipefail
shopt -s nullglob
packages=(releases/*.eap)
if [ ${#packages[@]} -eq 0 ]; then
echo "no .eap produced" >&2
exit 1
fi
for package in "${packages[@]}"; do
echo "== $package"
tar tzf "$package" >/dev/null
done
# 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
- uses: actions/upload-artifact@v7
with:
name: packages
path: releases/*.eap
if-no-files-found: error
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"
# Unstripped binaries for symbolising a crash from a shipped (stripped)
# package. Not a release asset: they are only useful while debugging.
- uses: actions/upload-artifact@v7
with:
name: debug-symbols
path: debug/
if-no-files-found: ignore
- name: Compare versions
id: compare
run: |
echo "Repo version: $CURRENT_VERSION"
echo "Latest Tailscale version: $RELEASE_VERSION"
echo "Trigger: ${{ github.event_name }}"
# Only after a successful build, so a failed upstream jump leaves main clean.
- name: Commit version bump
if: needs.check.outputs.release == 'true' && github.event_name != 'pull_request'
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Packages are already uploaded as an artifact; removing them here
# keeps build output out of the commit regardless of .gitignore.
rm -rf releases
git add -A
if git diff --cached --quiet; then
echo "nothing to commit"
exit 0
fi
git commit -m "Update to $VERSION"
# The remote can move while a long build runs, so rebase and retry.
for attempt in 1 2 3; do
if git push; then
exit 0
fi
echo "push rejected, rebasing (attempt $attempt)"
git pull --rebase --autostash origin main
done
echo "could not push the version bump" >&2
exit 1
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
release:
needs: [check, build]
if: needs.check.outputs.release == 'true' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.check.outputs.version }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
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
- uses: actions/download-artifact@v8
with:
name: packages
path: releases
# 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
# Stays a DRAFT: unsigned packages must never reach users, and an
# already-published release must never be overwritten with unsigned ones.
- name: Create or refresh draft release
run: |
set -euo pipefail
tag="v$VERSION"
./ci/release-notes.sh "$VERSION" "${{ needs.check.outputs.upstream }}" > /tmp/notes.md
cat /tmp/notes.md
if gh release view "$tag" --json isDraft --jq '.isDraft' 2>/dev/null | grep -qx true; then
gh release upload "$tag" releases/*.eap --clobber
gh release edit "$tag" --notes-file /tmp/notes.md
elif gh release view "$tag" >/dev/null 2>&1; then
echo "release $tag is already published; refusing to touch it" >&2
exit 1
# 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
gh release create "$tag" releases/*.eap \
--draft \
--title "Tailscale VPN $VERSION" \
--notes-file /tmp/notes.md
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" == *_acap3 ]]; then
VARIANT="_acap3"
else
VARIANT=""
fi
# Update version — manifest.json for ACAP 4, package.conf for ACAP 3
if [[ -f "$folder/app/manifest.json" ]]; then
sed -i "s/\"version\": \".*\"/\"version\": \"${RELEASE_VERSION}\"/" "$folder/app/manifest.json"
elif [[ -f "$folder/app/package.conf" ]]; then
IFS='.' read -r MAJOR MINOR MICRO <<< "${RELEASE_VERSION}"
sed -i "s/^APPMAJORVERSION=.*/APPMAJORVERSION=${MAJOR}/" "$folder/app/package.conf"
sed -i "s/^APPMINORVERSION=.*/APPMINORVERSION=${MINOR}/" "$folder/app/package.conf"
sed -i "s/^APPMICROVERSION=.*/APPMICROVERSION=${MICRO}/" "$folder/app/package.conf"
fi
# Docker build
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"
CID=$(docker create "$TAG_NAME")
docker cp "$CID":/opt/app "$EAP_OUTPUT"
docker rm "$CID" >/dev/null
# Move all .eap files to releases folder, append variant if needed
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 and ACAP 3 package.conf; do not track release artifacts
git add */app/manifest.json arm_acap3/app/package.conf
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 }}
-22
View File
@@ -1,22 +0,0 @@
---
name: Lint
on: push
jobs:
Build:
name: Lint code base
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Environment
run: cat .github/super-linter.env >> "$GITHUB_ENV"
- name: Lint code base
uses: super-linter/super-linter/slim@v8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -5
View File
@@ -1,13 +1,9 @@
**/.DS_Store
**/build
# Do not track release artifacts (local .eap build outputs stay untracked anywhere in the tree)
# Do not track release artifacts
releases/
build/
*.eap
# Unstripped binaries kept for crash symbolisation, uploaded as a CI artifact
debug/
# Do not track downloaded Tailscale tarballs and temp bins
tailscale_bins/
-8
View File
@@ -1,8 +0,0 @@
---
# Enforce error-level Dockerfile correctness. Warnings/info are advisory: the
# ACAP cross-compile Dockerfiles use accepted patterns (cd in RUN, ARG-templated
# FROM tags hadolint cannot resolve, optional pipefail).
failure-threshold: error
ignored:
# Pin versions in 'apt-get install' - the SDK base image is already pinned.
- DL3008
-11
View File
@@ -1,11 +0,0 @@
---
# Line length (disabled: long lines in tables, URLs and prose are acceptable)
MD013: false
# Allow inline HTML (e.g. <img> logos and badges in READMEs)
MD033: false
# Allow blank lines inside blockquotes
MD028: false
# First line in a file should be a top-level heading
MD041: false
# Table column style (disabled; the previous "padded" value was invalid)
MD060: false
-2
View File
@@ -1,2 +0,0 @@
rules:
line-length: disable
-157
View File
@@ -1,157 +0,0 @@
# Changelog
All notable changes to this project are documented here. Each version
links to its full release notes on GitHub.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## 1.102.4 - 2026-09-11
- Update to upstream 1.102.4.
## [1.98.9-Signed] - 2026-07-21 - Tailscale VPN 1.98.9 (Signed)
- Packages are now signed with the Axis ACAP signing service and install
normally on AXIS OS 12.10 and later.
- Vendor updated to `moshe@mohome.net` with the registered vendor ID.
- `root` and `acap3` variants remain unsigned (manifest schema v1.x).
- Upgrading from an earlier unsigned version can fail with "Couldn't
install: app" (device log: "Vendor ID in manifest does not match the
vendor ID of the previous version"). Back up your config, uninstall the
old version, then install this one.
## [1.98.8-2] - 2026-07-03 - Tailscale VPN 1.98.8-2
## [1.98.8-subnet-routing] - 2026-07-01 - Tailscale VPN 1.98.8 - Subnet Routing
## [1.98.8] - 2026-06-30 - Tailscale VPN 1.98.8
## [1.98.4-statusfix] - 2026-06-16
## [1.98.4-dns-routes] - 2026-06-10 - Tailscale VPN v1.98.4 - Accept DNS & Routes toggles
## [1.98.4] - 2026-06-02 - Tailscale VPN 1.98.4
## [1.98.3] - 2026-05-22 - Tailscale VPN 1.98.3
## [1.98.2] - 2026-05-19 - Tailscale VPN 1.98.2
## [1.96.4-dns-routes] - 2026-05-12 - Tailscale VPN v1.96.4 - Accept DNS & Routes toggles
## [1.96.4-r3] - 2026-04-17 - Tailscale VPN v1.96.4-r3
## [1.96.4-r2] - 2026-04-17
## [1.96.4-proxy] - 2026-04-14 - Tailscale VPN 1.96.4 - Proxy Support
## [1.96.4] - 2026-03-28 - Tailscale VPN 1.96.4
## [1.96.2] - 2026-03-19 - Tailscale VPN 1.96.2
## [1.94.2] - 2026-02-26 - Tailscale VPN 1.94.2
## [1.94.1] - 2026-01-28 - Tailscale VPN 1.94.1
## [1.92.5] - 2026-01-07 - Tailscale VPN 1.92.5
## [1.92.3] - 2025-12-17 - Tailscale VPN 1.92.3
## [1.92.1] - 2025-12-15 - Tailscale VPN 1.92.1
## [1.90.9] - 2025-11-26 - Tailscale VPN 1.90.9
## [1.90.8] - 2025-11-20 - Tailscale VPN 1.90.8
## [1.90.6] - 2025-11-03 - Tailscale VPN 1.90.6
## [1.90.3] - 2025-10-28 - Tailscale VPN 1.90.3
## [1.90.2] - 2025-10-27 - Tailscale VPN 1.90.2
## [1.90.1] - 2025-10-23 - Tailscale VPN 1.90.1
## [1.88.3] - 2025-09-29 - Tailscale VPN 1.88.3
## [1.88.1] - 2025-09-15 - Tailscale VPN 1.88.1
## [1.86.2] - 2025-08-27 - Tailscale VPN 1.86.2
## [1.84.0] - 2025-05-26
## [1.82.0] - 2025-04-11
## [1.80.3] - 2025-03-24
## [1.78.1] - 2025-01-13
## [1.76.1] - 2024-10-24
## [1.72.1] - 2024-08-26
## [1.68.1] - 2024-06-27
## [1.62.0] - 2024-03-23
## [1.60.0] - 2024-02-21
## [1.56.1] - 2024-01-17
## [1.54.0] - 2023-11-28
## [1.52.0] - 2023-11-01
## [1.50.1] - 2023-10-16
## [148.2] - 2023-09-13 - Version 1.48.2
## [1.44.0] - 2023-07-04
## [138.4] - 2023-04-17 - V1.38.4
## [1.34.0] - 2022-12-19
[1.98.8-2]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.98.8-2
[1.98.8-subnet-routing]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.98.8-subnet-routing
[1.98.8]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.98.8
[1.98.4-statusfix]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.98.4-statusfix
[1.98.4-dns-routes]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.98.4-dns-routes
[1.98.4]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.98.4
[1.98.3]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.98.3
[1.98.2]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.98.2
[1.96.4-dns-routes]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.96.4-dns-routes
[1.96.4-r3]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.96.4-r3
[1.96.4-r2]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.96.4-r2
[1.96.4-proxy]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.96.4-proxy
[1.96.4]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.96.4
[1.96.2]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.96.2
[1.94.2]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.94.2
[1.94.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.94.1
[1.92.5]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.92.5
[1.92.3]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.92.3
[1.92.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.92.1
[1.90.9]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.90.9
[1.90.8]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.90.8
[1.90.6]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.90.6
[1.90.3]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.90.3
[1.90.2]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.90.2
[1.90.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.90.1
[1.88.3]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.88.3
[1.88.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.88.1
[1.86.2]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.86.2
[1.84.0]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.84.0
[1.82.0]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.82.0
[1.80.3]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.80.3
[1.78.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.78.1
[1.76.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.76.1
[1.72.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.72.1
[1.68.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.68.1
[1.62.0]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.62.0
[1.60.0]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.60.0
[1.56.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.56.1
[1.54.0]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.54.0
[1.52.0]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.52.0
[1.50.1]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.50.1
[148.2]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.148.2
[1.44.0]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.1.44.0
[138.4]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v.138.4
[1.34.0]: https://github.com/Mo3he/Axis_Cam_Tailscale/releases/tag/v1.34.0
+19 -19
View File
@@ -13,13 +13,13 @@ All types of contributions are encouraged and valued. See the [Table of contents
- [I have a question](#i-have-a-question)
- [I want to contribute](#i-want-to-contribute)
- [Reporting bugs](#reporting-bugs)
- [Before submitting a bug report](#before-submitting-a-bug-report)
- [How do I submit a good bug report?](#how-do-i-submit-a-good-bug-report)
- [Suggesting enhancements](#suggesting-enhancements)
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
- [How do I submit a good enhancement suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
- [Your first code contribution](#your-first-code-contribution)
- [Reporting bugs](#reporting-bugs)
- [Before submitting a bug report](#before-submitting-a-bug-report)
- [How do I submit a good bug report?](#how-do-i-submit-a-good-bug-report)
- [Suggesting enhancements](#suggesting-enhancements)
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
- [How do I submit a good enhancement suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
- [Your first code contribution](#your-first-code-contribution)
## I have a question
@@ -46,13 +46,13 @@ A good bug report shouldn't leave others needing to chase you up for more inform
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker][issues_bugs].
- Also make sure to search the internet to see if users outside of the GitHub community have discussed the issue.
- Collect information about the bug:
- Axis device model
- Axis device firmware version
- Stack trace
- OS and version (Windows, Linux, macOS, x86, ARM)
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant
- Possibly your input and the output
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
- Axis device model
- Axis device firmware version
- Stack trace
- OS and version (Windows, Linux, macOS, x86, ARM)
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant
- Possibly your input and the output
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
#### How do I submit a good bug report?
@@ -126,9 +126,9 @@ Before opening a Pull Request (PR), please consider the following guidelines:
And finally when you are satisfied with your changes, open a new PR.
<!-- markdownlint-disable MD034 -->
[issues]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues
[issues_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues/new
[issues_bugs]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues?q=label%3Abug
[discussions]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions
[discussions_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions/new
[issues]: https://github.com/AxisCommunications/tailscale-acap/issues
[issues_new]: https://github.com/AxisCommunications/tailscale-acap/issues/new
[issues_bugs]: https://github.com/AxisCommunications/tailscale-acap/issues?q=label%3Abug
[discussions]: https://github.com/AxisCommunications/tailscale-acap/discussions
[discussions_new]: https://github.com/AxisCommunications/tailscale-acap/discussions/new
<!-- markdownlint-enable MD034 -->
+3 -2
View File
@@ -1,6 +1,7 @@
BSD 3-Clause License
Copyright (c) 2022, Weston Blieden
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:
@@ -25,4 +26,4 @@ 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.
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+131 -193
View File
@@ -1,114 +1,70 @@
# Tailscale ACAP for Axis Cameras
[![Release](https://img.shields.io/github/v/release/Mo3he/Axis_Cam_Tailscale?style=flat)](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
[![License](https://img.shields.io/github/license/Mo3he/Axis_Cam_Tailscale?style=flat)](LICENSE)
[![Downloads](https://img.shields.io/github/downloads/Mo3he/Axis_Cam_Tailscale/total?label=Downloads&color=blue&style=flat)](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
[![Build](https://github.com/Mo3he/Axis_Cam_Tailscale/actions/workflows/build.yml/badge.svg)](https://github.com/Mo3he/Axis_Cam_Tailscale/actions/workflows/build.yml)
[![Super-Linter](https://github.com/Mo3he/Axis_Cam_Tailscale/actions/workflows/super-linter.yml/badge.svg)](https://github.com/Mo3he/Axis_Cam_Tailscale/actions/workflows/super-linter.yml)
[![Sponsor](https://img.shields.io/badge/Sponsor%20My%20Work-EA4AAA?style=flat&logo=github&logoColor=white)](https://github.com/sponsors/Mo3he)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-FFDD00?style=flat&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/mo3he)
This repository provides an **ACAP package** that installs the
[Tailscale VPN client](https://tailscale.com/) on Axis cameras, for secure remote
access without extra hardware or complex network configuration.
**[Visit the Homepage](https://mo3he.github.io/Axis_Cam_Tailscale/)**
> **Disclaimer:** Independent, community-developed ACAP package. Not an official
> Axis product and not affiliated with, endorsed by, or supported by Axis
> Communications AB or Tailscale Inc. Use at your own risk.
This repository provides an **ACAP package** that installs the [Tailscale VPN client](https://tailscale.com/) on Axis cameras.
> **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).
- Secure remote access to cameras
- Easy to install via EAP package
- Works on **Axis OS 12+** (non-root version)
- Works on **legacy Axis OS 9.x / 10.x** via the ACAP 3 variant
- Based on **WireGuard VPN** technology
[![Releases](https://img.shields.io/github/v/release/Mo3he/Axis_Cam_Tailscale)](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
[![License](https://img.shields.io/github/license/Mo3he/Axis_Cam_Tailscale)](LICENSE)
![Total Downloads](https://img.shields.io/github/downloads/Mo3he/Axis_Cam_Tailscale/total?style=flat&label=Downloads&color=blue)
[![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-lightgrey?logo=github)](https://github.com/sponsors/Mo3he)
> **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
> **Tailscale Notice:** Tailscale is a product of Tailscale Inc. This package independently redistributes the Tailscale binaries under the [BSD 3-Clause License](LICENSE) and is not affiliated with, endorsed by, or supported by Tailscale Inc. For the official Tailscale client, visit [tailscale.com](https://tailscale.com).
---
## Table of Contents
- [Overview](#overview)
- [Compatibility](#compatibility)
- [Installation](#installation)
- [Configuration](#configuration)
- [Ports & security](#ports--security)
- [Accessing Tailnet services from the camera](#accessing-tailnet-services-from-the-camera)
- [Updating Tailscale](#updating-tailscale)
- [Build from source](#build-from-source)
- [Roadmap](#roadmap)
- [Links](#links)
- [License](#license)
- [Installation](#installation)
- [Usage](#usage)
- [Settings](#settings)
- [Proxy Support](#proxy-support)
- [Updating Tailscale](#updating-tailscale)
- [Purpose](#purpose)
- [Useful Links](#useful-links)
- [Compatibility](#compatibility)
- [Star History](#star-history)
- [Support](#support)
## Overview
Adding a VPN client directly to the camera enables secure remote access without
additional hardware or complex network configuration, through Tailscale's
lightweight WireGuard-based tunnel.
- Secure remote access to cameras.
- Easy to install via EAP package.
- Works on **AXIS OS 10.12+** (non-root version, verified across 10.12–13).
- Works on **legacy AXIS OS 9.x / 10.x** via the ACAP 3 variant.
- Based on **WireGuard VPN** technology.
Tailscale ACAP runs **without root privileges** in userspace networking mode,
making it compatible with AXIS OS 10.12+. For **full kernel networking**, use the
**ROOT** version (AXIS OS 10.12–11.x only; AXIS OS 12 and later removed root
access for third-party applications). Learn more:
[How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/).
## Compatibility
| Build | AXIS OS | Architecture | Notes |
|---|---|---|---|
| ACAP 4 (native SDK) | 10.12 – 13 | aarch64 | Standard, userspace networking |
| ACAP 4 (native SDK) | 10.12 – 13 | armv7hf | Standard, userspace networking |
| ACAP 4 root | 10.12 – 11.x | aarch64 | Full kernel networking (not on OS 12+) |
| ACAP 4 root | 10.12 – 11.x | armv7hf | Full kernel networking (not on OS 12+) |
| ACAP 3 (legacy SDK) | 9.x – 10.x | armv7hf | Legacy cameras |
> Most cameras use the standard **ACAP 4** build. The **root** builds add
> kernel-level networking but only run on AXIS OS 10.12–11.x (AXIS OS 12+ removed
> root access for ACAPs). Use the **ACAP 3** build only on legacy cameras that
> don't support ACAP 4 (AXIS OS 9–10).
**Verified on AXIS OS 13** (13.0.0, aarch64).
---
## Installation
> **Signed packages:** Release `.eap` files are signed with the Axis ACAP
> signing service and install normally on AXIS OS 12.10 and later.
>
> **Upgrading from an earlier version?** The signing vendor changed, so
> installing over a previously installed unsigned build can fail with
> **"Couldn't install: app"** (device log: *"Vendor ID in manifest does not
> match the vendor ID of the previous version"*). To upgrade: back up your app
> configuration, **uninstall** the old version, then install the signed one.
Get the **prebuilt `.eap` file** from the [Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
Get the **prebuilt `.eap` file** from the
[Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
1. Log into your Axis camera.
2. Go to **Apps -> Add App**.
3. Upload the `.eap` file.
1. Log into your Axis camera.
2. Go to **Apps → Add App**.
3. Upload the `.eap` file.
Once installed:
- Start the app.
- Click **Open** to view logs and get your Tailscale authentication URL.
- On uninstall, all changes/files are removed.
- Start the app.
- Click **Open** to view logs and get your Tailscale authentication URL.
- On uninstall, all changes/files are removed.
> You'll need a [Tailscale account](https://tailscale.com/) to authenticate.
## Configuration
---
The app runs a C-based parameter bridge that reads settings from the ACAP
parameter store and launches Tailscale. View logs and connection status via the
**Open** button in the app, and authenticate using the provided URL or by
pre-entering an auth key in **Settings**. Parameter changes (ports, server URL,
auth key) are applied automatically without reinstalling the app.
## Usage
All parameters are configurable via the web UI (**Open -> Settings** card) and
take effect immediately:
- Runs a C-based parameter bridge (compiled via ACAP SDK 1.15.1) that reads settings from the ACAP parameter store and launches Tailscale.
- View logs and connection status via the **Open** button in the app.
- Authenticate using the provided URL, or pre-enter an auth key in **Settings**.
- Change the **Custom Server URL** in Settings to use a self-hosted [Headscale](https://headscale.net/) control server.
- Parameter changes (ports, server URL, auth key) are applied automatically without needing to reinstall the app.
---
## Settings
All parameters are configurable via the web UI (**Open → Settings** card) and take effect immediately without reinstalling:
| Parameter | Default | Description |
|---|---|---|
@@ -116,140 +72,122 @@ take effect immediately:
| Auth Key | *(empty)* | Pre-authentication key (`tskey-auth-...`). Cleared automatically after first successful connection. Leave blank to authenticate via browser. |
| HTTP Proxy Port | `8080` | Port for the outbound HTTP/HTTPS proxy. |
| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 proxy. |
| Accept DNS | `off` | Passes `--accept-dns=true` to `tailscale up`. Allows the tailnet to push DNS settings to the camera. Not available on `armv7hf_acap3`. |
| Accept Routes | `off` | Passes `--accept-routes=true` to `tailscale up`. Allows the camera to use subnet routes advertised by other nodes. Not available on `armv7hf_acap3`. |
| Advertise Routes (Subnet Router) | *(empty)* | Comma-separated CIDRs (e.g. `192.168.1.0/24,10.0.0.0/8`) this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving. Leave blank to disable. |
## Ports & security
---
All non-ROOT variants expose two local proxy endpoints that route outbound
traffic through the Tailscale tunnel. The ports are configurable via **Settings
-> HTTP Proxy Port / SOCKS5 Proxy Port**.
| Proxy | Default address | Routes |
All non-ROOT variants expose two local proxy endpoints that route outbound traffic through the Tailscale tunnel. The ports are configurable via **Settings → HTTP Proxy Port / SOCKS5 Proxy Port** in the web UI.
### HTTP CONNECT Proxy — `http://127.0.0.1:8080` (default)
Routes HTTP and HTTPS traffic. Set this wherever an HTTP/HTTPS proxy field is available on the camera:
| Location | Field | Value |
|---|---|---|
| HTTP CONNECT | `http://127.0.0.1:8080` | HTTP and HTTPS traffic |
| SOCKS5 | `127.0.0.1:1080` | Any SOCKS5-aware app or service |
| System → Network → Global proxies | HTTP proxy | `http://127.0.0.1:<port>` |
| System → Network → Global proxies | HTTPS proxy | `http://127.0.0.1:<port>` |
| System → MQTT → Broker | HTTP proxy | `http://127.0.0.1:<port>` |
| System → MQTT → Broker | HTTPS proxy | `http://127.0.0.1:<port>` |
Set the HTTP CONNECT proxy wherever an HTTP/HTTPS proxy field is available on the
camera (System -> Network -> Global proxies; System -> MQTT -> Broker). For
SOCKS5-aware apps, set their proxy to `127.0.0.1:<port>`.
### SOCKS5 Proxy — `127.0.0.1:1080` (default)
> **Security:** the proxies bind to **loopback only** (`127.0.0.1`), so they are
> not exposed on the camera's network interface, the least-exposed of the VPN
> ACAPs. The active proxy addresses are shown in the **Proxy Configuration** card
> of the web UI. If you change a port that is already in use, the app logs an
> error and exits rather than silently falling back.
For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:<port>`.
## Accessing Tailnet services from the camera
> The active proxy addresses are always shown in the **Proxy Configuration** card of the web UI.
There is an important asymmetry. Making the camera **reachable from** the tailnet
(browsing to it, VAPIX, SSH from another tailnet node) works on every build. The
harder direction is the camera **reaching out to** a tailnet peer, for example
mounting an SMB/CIFS share hosted on another node. How well this works depends on
the build:
> If you change a port that is already in use by another process, the app will log an error and exit rather than silently falling back to a different port.
| Build | Networking mode | Camera-initiated access to tailnet peers |
|---|---|---|
| Non-root (`aarch64`, `armv7hf`) and `armv7hf_acap3` | `--tun=userspace-networking` (no kernel `tailscale0`) | Only through the local **SOCKS5 / HTTP proxies**, and only for **proxy-aware** apps. Firmware system services (SMB client, NTP, etc.) are proxy-unaware and **cannot** reach a peer's `100.x` IP directly. |
| **ROOT** (`aarch64_root`, `armv7hf_root`) | Kernel networking with a real `tailscale0` interface | Peer `100.x` IPs are routable at the OS level, so firmware services **can** connect directly. Enable **Accept Routes** to also reach subnets behind other nodes. |
### Plan B: reverse-SSH tunnel
> **Requires root on the camera.** Port 445 is privileged, so binding it needs a
> root-capable build (e.g. developer certificates installed).
If you cannot use the ROOT build but still need the camera to mount a share on a
machine that is on your tailnet, make the remote share appear **local** to the
camera with a reverse SSH tunnel. Because the destination becomes `127.0.0.1`, the
proxy-unaware SMB client never has to route over the tailnet.
```bash
# Forward the camera's local port 445 back to the SMB share on this machine
ssh -R 445:localhost:445 root@<camera-tailscale-ip>
```
Then, in **System -> Storage -> Add network share**, use `127.0.0.1` as the share
host and connect.
---
## 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.
- 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 `common/app/lib/` (shared by `aarch64`, `armv7hf`, and
their ROOT variants) or `arm_acap3/app/lib/` (legacy variant, kept separate):
Replace the binaries in the `lib/` folder:
- `tailscale`
- `tailscaled`
Download the latest versions:
[Tailscale static builds](https://pkgs.tailscale.com/stable/#static).
Download the latest versions: [Tailscale static builds](https://pkgs.tailscale.com/stable/#static)
## Build from source
#### Build locally
The Tailscale binaries are not stored in git, so first download them (see
[Manual update](#manual-update-advanced)) and place them in `common/app/lib/`, or
`arm_acap3/app/lib/` for the legacy variant.
All variants build from the **repository root**, pointing at the variant's own
`Dockerfile`:
From the main directory of the version you want (`arm` / `aarch64`):
```bash
docker build -f aarch64/Dockerfile --tag <package_name> .
docker build --tag <package_name> .
docker cp $(docker create <package_name>):/opt/app ./build
```
(Same for the others: just swap in `arm/Dockerfile`, `aarch64_ROOT/Dockerfile`,
`arm_ROOT/Dockerfile`, or `arm_acap3/Dockerfile`.)
---
## Roadmap
## Good News
### AXIS OS 13 Preparation
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 12+**.
AXIS OS 13 (scheduled for September 2026) introduces several breaking changes that
affect all ACAP applications. See the full
[AXIS OS 13 breaking changes](https://www.axis.com/for-developers/news/AXIS-OS-13-breaking-changes)
announcement for details.
- Runs in **user space networking mode**.
- [x] **Recompile for 64-bit time (Y2038)** - Done for the standard
`aarch64`/`armv7hf` builds (now built against ACAP Native SDK 12.10.0); the
ROOT variants intentionally stay on the older SDK since AXIS OS 12+ never
supports root third-party apps.
- [x] **Migrate to Manifest Schema v2** - Done for `aarch64`/`armv7hf` (schema
2.0.0, `compatibleOsVersions` declared); verified installability on OS
10.12–13.
- [x] **Audit for executable stack usage** - All compiled binaries report
`flags rw-` (no executable stack) on every architecture and variant.
- [x] **Verify web UI works over HTTPS** - Verified live; the UI only issues
relative-path requests, so it inherits the page's protocol with no
mixed-content risk.
- [x] **Sign the ACAP via the Axis ACAP Portal** - Done; `aarch64`/`armv7hf`
packages are signed with the Axis ACAP signing service. The `root` and
`acap3` variants use manifest schema v1.x and are distributed unsigned.
For **full networking features**, use the **ROOT** version on Axis OS < 12.
### General Improvements
### Legacy camera support (Axis OS 9.x / 10.x)
- [x] **Accept DNS from tailnet toggle** - Opt-in setting passing
`--accept-dns=true` to `tailscale up` (defaults off).
- [x] **Accept routes toggle** - Opt-in setting passing `--accept-routes=true`.
- [ ] **Switch to tiny-tailscale binaries** - Evaluate replacing the bundled
`tailscale`/`tailscaled` with [tiny-tailscale](https://github.com/iamromulan/tiny-tailscale)
builds (single binary, ~43% smaller).
An **ACAP 3** variant (`armv7hf_acap3`) is available for older cameras that do not support ACAP 4 / Axis OS 11+. It uses the same userspace networking mode and web UI, built against the ACAP SDK 3.5 toolchain.
## Links
---
- [Tailscale](https://tailscale.com/)
- [Tailscale GitHub](https://github.com/tailscale/tailscale)
- [WireGuard](https://www.wireguard.com/)
- [Axis Communications](https://www.axis.com/)
## Purpose
## License
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.
The packaging code in this repository is licensed under BSD 3-Clause (see
[LICENSE](LICENSE)); this also covers the redistributed Tailscale binaries
(upstream Tailscale is BSD 3-Clause). Bundled upstream components are listed in
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
Learn more: [How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/)
---
## Useful Links
- [Tailscale](https://tailscale.com/)
- [Tailscale GitHub](https://github.com/tailscale/tailscale)
- [WireGuard](https://www.wireguard.com/)
- [Axis Communications](https://www.axis.com/)
---
## Compatibility
The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**-based SoCs.
| Variant | Architecture | Axis OS | Notes |
|---|---|---|---|
| `aarch64` | AArch64 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports |
| `armv7hf` | ARMv7 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports |
| `aarch64_root` | AArch64 | 10 or earlier | Full kernel networking (root) |
| `armv7hf_root` | ARMv7 | 10 or earlier | Full kernel networking (root) |
| `armv7hf_acap3` | ARMv7 | **9.x – 10.x** | Legacy cameras, ACAP SDK 3 |
> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 11.11+ → use the standard variant (`aarch64` or `armv7hf`). Axis OS 9/10 on ARMv7 → use `armv7hf_acap3`. Axis OS 10 or earlier on AArch64 → use `aarch64_root`.
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
[![Star History Chart](https://api.star-history.com/svg?repos=Mo3he/Axis_Cam_Tailscale&type=Date)](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date)
---
## Support
If you like this project and want to support my work:
[Sponsor Me](https://github.com/sponsors/Mo3he)
-23
View File
@@ -1,23 +0,0 @@
# Security Policy
This is an independent, community-developed ACAP package, provided on a
best-effort basis. It is not an official Axis Communications product.
## Reporting a vulnerability
Please report security issues privately rather than in a public issue:
- Use GitHub's "Report a vulnerability" (Security > Advisories) to open a
private advisory, or
- email <moshe@mohome.net>.
Include the affected version (or `.eap` filename), camera model / Axis OS
version, a description and its impact, and reproduction steps if available. You
can expect an acknowledgement within a reasonable time; please avoid public
disclosure until a fix is released.
## Scope
Reports about this ACAP's own wrapper code, configuration handling, and default
settings are in scope. Vulnerabilities in bundled upstream projects should also
be reported to their respective upstream projects.
+3 -5
View File
@@ -1,15 +1,13 @@
ARG ARCH=aarch64
ARG VERSION=12.10.0
ARG UBUNTU_VERSION=24.04
ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp
ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY common/app /opt/app/
COPY aarch64/app/manifest.json /opt/app/manifest.json
COPY ./app /opt/app/
WORKDIR /opt/app
ENV EXTRA_CFLAGS=-DHAS_PROXY_PORTS
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+2 -2
View File
@@ -1,5 +1,5 @@
To build, from main directory
docker build --tag aarch64 .
docker build --tag aarch64 .
docker cp $(docker create aarch64):/opt/app ./build
docker cp $(docker create aarch64):/opt/app ./build
+1 -2
View File
@@ -1,8 +1,7 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0 gio-2.0
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
CFLAGS += $(EXTRA_CFLAGS)
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
# Tailscale VPN run script — called by the param_bridge C binary.
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Defaults — overridden by sourcing params.conf written by param_bridge
CUSTOM_SERVER=""
AUTH_KEY=""
CONF_HTTP="8080"
CONF_SOCKS="1080"
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
# Check whether a TCP port is already bound
is_port_in_use() {
local port=$1
local hex_port
hex_port=$(printf '%04X' "$port")
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
return 1
}
if is_port_in_use "$CONF_HTTP"; then
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
exit 1
fi
if is_port_in_use "$CONF_SOCKS"; then
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
exit 1
fi
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
--socks5-server=localhost:$CONF_SOCKS \
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
--tun=userspace-networking \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running"
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS"
wait $TAILSCALED_PID
@@ -11,7 +11,7 @@
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #2e2d2d;
--accent: #4f8ff7;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
@@ -26,7 +26,7 @@
--border: #e0e3e8;
--text: #1a1a2e;
--muted: #6b7084;
--accent: #2e2d2d;
--accent: #2563eb;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -231,7 +231,7 @@
word-break: break-all;
}
.log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.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); }
@@ -272,23 +272,6 @@
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Toggle switch */
.toggle-row { display: flex; align-items: flex-start; gap: 12px; }
.toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--border); border-radius: 20px; transition: background 0.2s;
}
.toggle-slider:before {
content: ''; position: absolute;
height: 14px; width: 14px; left: 3px; bottom: 3px;
background: white; border-radius: 50%; transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--green); }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(16px); }
.toggle-info { flex: 1; }
/* Refresh indicator */
.refresh-bar {
display: flex;
@@ -308,8 +291,8 @@
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(46,45,45,0.1);
border: 1px solid rgba(46,45,45,0.2);
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 {
@@ -343,6 +326,18 @@
<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">
@@ -403,8 +398,8 @@
</div>
</div>
<!-- Proxy Info (hidden on ROOT builds, which have no local proxy) -->
<div class="card" id="proxy-info-card" style="display:none;">
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
@@ -424,48 +419,23 @@
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<input class="settings-input" id="input-server" type="text" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
<input class="settings-input" id="input-authkey" type="password" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row" id="http-port-row" style="display:none;">
<div class="settings-row">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
<input class="settings-input" id="input-http-port" type="text" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row" id="socks-port-row" style="display:none;">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-dns">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept DNS</div>
<span class="settings-hint">Pass <code>--accept-dns=true</code> to tailscale up. Allows the tailnet to push DNS settings to this device. Off by default to avoid overriding the camera&apos;s DNS configuration.</span>
</div>
</div>
<div class="settings-row toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="input-accept-routes">
<span class="toggle-slider"></span>
</label>
<div class="toggle-info">
<div class="settings-label">Accept Routes</div>
<span class="settings-hint">Pass <code>--accept-routes=true</code> to tailscale up. Allows this device to use subnet routes advertised by other nodes in the tailnet.</span>
</div>
</div>
<div class="settings-row">
<label class="settings-label" for="input-advertise-routes">Advertise Routes (Subnet Router)</label>
<input class="settings-input" id="input-advertise-routes" type="text" autocomplete="off" placeholder="192.168.1.0/24,10.0.0.0/8 (leave blank to disable)">
<span class="settings-hint">Comma-separated CIDRs this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving.</span>
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
@@ -495,7 +465,6 @@
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var STATUS_URL = 'status.json';
var logBox = document.getElementById('log-box');
var autoScroll = true;
@@ -743,82 +712,29 @@
.catch(function() { return false; });
}
// Ground truth published by the run script from `tailscale status --json`.
function fetchStatus() {
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.catch(function() { return null; });
}
// Apply Tailscale's authoritative backend state onto the result object.
function applyStatus(result, st) {
var self = st.Self || {};
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
var ip4 = null;
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
var bs = st.BackendState;
if (st.Version) result.version = String(st.Version).split('-')[0];
if (bs === 'Running' && self.Online === true) {
// Genuinely connected and reachable on the tailnet
result.state = 'connected';
result.url = null;
result.ip = ip4 || result.ip;
result.node = self.HostName || result.node;
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
cacheSet('ip', result.ip); cacheSet('node', result.node);
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Running') {
// Backend running but node not online: either a transient network
// drop (no action needed) or the node was removed/expired and needs
// re-auth. Not connected. Keep any login URL the log parser found
// (status.json's AuthURL lags during the `tailscale up` re-auth
// window) so the login button still appears when re-auth is needed.
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Stopped') {
result.state = 'disconnected';
result.url = null;
} else {
// NoState / Starting / unknown
result.state = 'connecting';
result.url = st.AuthURL || result.url;
}
}
function refresh() {
Promise.all([
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.catch(function() { return ''; }),
fetchStatus()
]).then(function(arr) {
var txt = arr[0];
var st = arr[1];
var result = parse(txt || '');
if (txt) renderLogs(txt);
// Verify the app is actually running - status.json can be stale if stopped
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (st && st.BackendState) {
// Authoritative: Tailscale's own backend state
applyStatus(result, st);
} else if (!result.url && result.state !== 'connected') {
// Fallback to log heuristic when status.json is unavailable
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);
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
});
}
refresh();
@@ -875,84 +791,28 @@
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var acceptDnsInput = document.getElementById('input-accept-dns');
var acceptRoutesInput = document.getElementById('input-accept-routes');
var advertiseRoutesInput = document.getElementById('input-advertise-routes');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
// param.cgi is used when available; on devices that lack it (e.g. some
// recorder/NVR-class devices) we fall back to the app's own endpoint,
// exposed through the manifest reverseProxy mapping at API_URL.
var API_URL = '/local/' + APP + '/api/settings';
// Whether this build exposes local HTTP/SOCKS5 proxies (absent on ROOT
// builds, which use kernel networking directly). Detected from whichever
// settings response actually comes back — set once and used to hide the
// proxy card/fields and to keep them out of the save request, since
// param.cgi errors the whole call's status line if asked to set a
// parameter name the manifest never registered.
var hasProxyPorts = false;
function toggleProxyUi(visible) {
hasProxyPorts = visible;
var display = visible ? '' : 'none';
document.getElementById('proxy-info-card').style.display = display;
document.getElementById('http-port-row').style.display = display;
document.getElementById('socks-port-row').style.display = display;
}
function updateProxyDisplay(httpPort, socksPort) {
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
}
function applyParamText(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
var avm = txt.match(/root\.\S+\.AdvertiseRoutes=(.*)/);
// If none of the expected keys are present the endpoint isn't param.cgi
// (e.g. a generic 404 page); signal the caller to use the fallback.
if (!sm && !hm && !km) return false;
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
if (avm) advertiseRoutesInput.value = avm[1].trim();
toggleProxyUi(!!hm && !!km);
updateProxyDisplay(hm ? hm[1].trim() : null, km ? km[1].trim() : null);
return true;
}
function applyJson(obj) {
if (typeof obj.CustomServer === 'string') serverInput.value = obj.CustomServer;
if (typeof obj.AuthKey === 'string') authInput.value = obj.AuthKey;
if (typeof obj.HttpProxyPort === 'string') httpPortInput.value = obj.HttpProxyPort;
if (typeof obj.Socks5Port === 'string') socksPortInput.value = obj.Socks5Port;
if (typeof obj.AcceptDNS === 'string') acceptDnsInput.checked = obj.AcceptDNS === 'true';
if (typeof obj.AcceptRoutes === 'string') acceptRoutesInput.checked = obj.AcceptRoutes === 'true';
if (typeof obj.AdvertiseRoutes === 'string') advertiseRoutesInput.value = obj.AdvertiseRoutes;
toggleProxyUi(typeof obj.HttpProxyPort === 'string' && typeof obj.Socks5Port === 'string');
updateProxyDisplay(obj.HttpProxyPort, obj.Socks5Port);
}
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) { if (!applyParamText(txt)) return Promise.reject(); })
.catch(function() { loadSettingsFallback(); });
}
function loadSettingsFallback() {
fetch(API_URL + '?t=' + Date.now(), { credentials: 'same-origin', cache: 'no-store' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(obj) { if (obj) applyJson(obj); })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
@@ -962,32 +822,6 @@
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
function saveViaFallback(httpPort, socksPort) {
var body = 'CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&AuthKey=' + encodeURIComponent(authInput.value.trim()) +
(hasProxyPorts ? '&HttpProxyPort=' + encodeURIComponent(httpPort) : '') +
(hasProxyPorts ? '&Socks5Port=' + encodeURIComponent(socksPort) : '') +
'&AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
return fetch(API_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
if (/OK/.test(txt)) {
// The app applies the change and restarts its tunnel itself,
// so no separate control.cgi restart is needed here.
setStatus('Saved. Restarting...', 'ok');
} else {
setStatus('Error saving settings', 'err');
}
});
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
@@ -996,34 +830,30 @@
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
(hasProxyPorts ? '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) : '') +
(hasProxyPorts ? '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) : '') +
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
'&root.' + APP + '.AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
// param.cgi reachable but rejected the update — surface the error.
setStatus('Error: ' + txt.trim(), 'err');
})
.catch(function() {
// param.cgi unavailable (e.g. recorder-class device) — use the fallback.
return saveViaFallback(httpPort, socksPort);
})
.then(function() { saveBtn.disabled = false; })
.catch(function() { saveBtn.disabled = false; setStatus('Failed to save', 'err'); });
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+36 -63
View File
@@ -1,67 +1,40 @@
{
"schemaVersion": "2.0.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "moshe@mohome.net",
"vendorId": "70ee172dd9",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"version": "1.102.4",
"architecture": "aarch64",
"runMode": "respawn",
"compatibleOsVersions": [
{
"max": "13"
"schemaVersion": "1.7.0",
"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",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
}
]
}
]
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
}
}
+251
View File
@@ -0,0 +1,251 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN (userspace variant).
*
* Responsibilities:
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
* 2. Write them to CONFIG_FILE so the shell script can source them.
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
* of the child so the new config is picked up.
* Rapid changes within 300 ms are coalesced into a single restart.
* 5. Watchdog: if the child exits unexpectedly, restart it.
*
* Runs as the unprivileged 'sdk' ACAP user — no root required.
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
/* ── watchdog ────────────────────────────────────────────────────────────── */
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
start_child();
}
}
return G_SOURCE_CONTINUE;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
cache_get(&cfg_http_proxy_port, "8080"),
cache_get(&cfg_socks5_port, "1080"),
cache_get(&cfg_custom_server, "(default)"));
}
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
static AXParameter *g_ax_handle = NULL;
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
/* ── main ────────────────────────────────────────────────────────────────── */
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting");
/* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+1 -2
View File
@@ -7,8 +7,7 @@ ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY common/app /opt/app/
COPY aarch64_ROOT/app/manifest.json /opt/app/manifest.json
COPY ./app /opt/app/
WORKDIR /opt/app
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+2 -2
View File
@@ -1,5 +1,5 @@
To build, from main directory
docker build --tag aarch64 .
docker build --tag aarch64 .
docker cp $(docker create aarch64):/opt/app ./build
docker cp $(docker create aarch64):/opt/app ./build
@@ -1,20 +1,8 @@
# Third-Party Notices
BSD 3-Clause License
This ACAP package redistributes the Tailscale client. The ACAP's own wrapper
code is licensed separately (see `LICENSE`, BSD 3-Clause).
Copyright (c) 2020 Tailscale & AUTHORS.
All rights reserved.
## Tailscale
- Copyright (c) 2020 Tailscale & AUTHORS
- Project: <https://github.com/tailscale/tailscale>
- License: BSD 3-Clause
Tailscale is a product of Tailscale Inc. This package independently
redistributes the Tailscale binaries and is not affiliated with, endorsed by, or
supported by Tailscale Inc. For the official Tailscale client, visit
<https://tailscale.com>.
```text
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
@@ -25,9 +13,9 @@ modification, are permitted provided that the following conditions are met:
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.
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
@@ -38,5 +26,4 @@ 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.
```
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+14
View File
@@ -0,0 +1,14 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
# Tailscale VPN run script (ROOT / kernel networking variant).
# Sources config from params.conf written by param_bridge.
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
CUSTOM_SERVER=""
AUTH_KEY=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
wait $TAILSCALED_PID
+864
View File
@@ -0,0 +1,864 @@
<!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); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */
.refresh-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(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>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<input class="settings-input" id="input-authkey" type="password" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box');
var autoScroll = true;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
// Fallbacks only when syslog has rotated and no state transitions are visible.
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
// (handles stale Running entries in syslog after reinstall or token expiry)
if (isRunning && latestUrl) {
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
}
refresh();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})();
</script>
</body>
</html>
View File
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+30 -52
View File
@@ -1,56 +1,34 @@
{
"schemaVersion": "1.7.0",
"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.102.4",
"architecture": "aarch64"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
"schemaVersion": "1.7.0",
"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",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
}
]
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
}
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
* Same structure as regular param_bridge.c but without proxy port params.
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
start_child();
}
}
return G_SOURCE_CONTINUE;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
}
static AXParameter *g_ax_handle = NULL;
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting (root mode)");
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+3 -5
View File
@@ -1,15 +1,13 @@
ARG ARCH=armv7hf
ARG VERSION=12.10.0
ARG UBUNTU_VERSION=24.04
ARG VERSION=1.15.1
ARG UBUNTU_VERSION=22.04
ARG REPO=axisecp
ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY common/app /opt/app/
COPY arm/app/manifest.json /opt/app/manifest.json
COPY ./app /opt/app/
WORKDIR /opt/app
ENV EXTRA_CFLAGS=-DHAS_PROXY_PORTS
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+2 -2
View File
@@ -1,5 +1,5 @@
To build from main directory
docker build --tag arm .
docker build --tag arm .
docker cp $(docker create arm):/opt/app ./build
docker cp $(docker create arm):/opt/app ./build
+29
View File
@@ -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.
+14
View File
@@ -0,0 +1,14 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
# Tailscale VPN run script — called by the param_bridge C binary.
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Defaults — overridden by sourcing params.conf written by param_bridge
CUSTOM_SERVER=""
AUTH_KEY=""
CONF_HTTP="8080"
CONF_SOCKS="1080"
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
# Check whether a TCP port is already bound
is_port_in_use() {
local port=$1
local hex_port
hex_port=$(printf '%04X' "$port")
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
return 1
}
if is_port_in_use "$CONF_HTTP"; then
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
exit 1
fi
if is_port_in_use "$CONF_SOCKS"; then
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
exit 1
fi
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
--socks5-server=localhost:$CONF_SOCKS \
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
--tun=userspace-networking \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running"
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS"
wait $TAILSCALED_PID
+864
View File
@@ -0,0 +1,864 @@
<!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); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */
.refresh-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(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>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<input class="settings-input" id="input-authkey" type="password" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box');
var autoScroll = true;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
// Fallbacks only when syslog has rotated and no state transitions are visible.
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
// (handles stale Running entries in syslog after reinstall or token expiry)
if (isRunning && latestUrl) {
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
}
refresh();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})();
</script>
</body>
</html>
View File
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+36 -63
View File
@@ -1,67 +1,40 @@
{
"schemaVersion": "2.0.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "moshe@mohome.net",
"vendorId": "70ee172dd9",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"version": "1.102.4",
"architecture": "armv7hf",
"runMode": "respawn",
"compatibleOsVersions": [
{
"max": "13"
"schemaVersion": "1.7.0",
"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",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
}
]
}
]
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
}
}
+251
View File
@@ -0,0 +1,251 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN (userspace variant).
*
* Responsibilities:
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
* 2. Write them to CONFIG_FILE so the shell script can source them.
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
* of the child so the new config is picked up.
* Rapid changes within 300 ms are coalesced into a single restart.
* 5. Watchdog: if the child exits unexpectedly, restart it.
*
* Runs as the unprivileged 'sdk' ACAP user — no root required.
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
/* ── watchdog ────────────────────────────────────────────────────────────── */
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
start_child();
}
}
return G_SOURCE_CONTINUE;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
cache_get(&cfg_http_proxy_port, "8080"),
cache_get(&cfg_socks5_port, "1080"),
cache_get(&cfg_custom_server, "(default)"));
}
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
static AXParameter *g_ax_handle = NULL;
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
/* ── main ────────────────────────────────────────────────────────────────── */
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting");
/* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+1 -2
View File
@@ -7,8 +7,7 @@ ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY common/app /opt/app/
COPY arm_ROOT/app/manifest.json /opt/app/manifest.json
COPY ./app /opt/app/
WORKDIR /opt/app
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
+2 -2
View File
@@ -1,5 +1,5 @@
To build from main directory
docker build --tag arm .
docker build --tag arm .
docker cp $(docker create arm):/opt/app ./build
docker cp $(docker create arm):/opt/app ./build
+29
View File
@@ -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.
+14
View File
@@ -0,0 +1,14 @@
PROG = Tailscale_VPN
SRCS = param_bridge.c
PKGS = axparameter glib-2.0
CFLAGS += $(shell pkg-config --cflags $(PKGS))
LDADD = $(shell pkg-config --libs $(PKGS))
all: $(PROG)
chmod +x Tailscale_VPN_run
$(PROG): $(SRCS)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
clean:
rm -f $(PROG)
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
# Tailscale VPN run script (ROOT / kernel networking variant).
# Sources config from params.conf written by param_bridge.
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
CUSTOM_SERVER=""
AUTH_KEY=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
>/dev/null 2>&1 &
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --accept-routes --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
eval $TAILSCALE_CMD
UP_EXIT=$?
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
wait $TAILSCALED_PID
+864
View File
@@ -0,0 +1,864 @@
<!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); }
/* Settings form */
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; flex-direction: column; gap: 4px; }
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
.settings-input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: var(--mono);
padding: 8px 10px;
width: 100%;
outline: none;
}
.settings-input:focus { border-color: var(--accent); }
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
.save-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover { opacity: 0.9; }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-status { font-size: 12px; color: var(--muted); }
.save-status.ok { color: var(--green); }
.save-status.err { color: var(--red); }
/* Refresh indicator */
.refresh-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 11px;
color: var(--muted);
}
/* Update banner */
.update-banner {
display: none;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(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>
<!-- Proxy Info (always visible) -->
<div class="card">
<div class="card-title">Proxy Configuration</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">HTTP/HTTPS Proxy</div>
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
</div>
<div class="info-item">
<div class="info-label">SOCKS5 Proxy</div>
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
<div class="settings-row">
<label class="settings-label" for="input-server">Custom Server URL</label>
<input class="settings-input" id="input-server" type="text" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-authkey">Auth Key</label>
<input class="settings-input" id="input-authkey" type="password" placeholder="tskey-auth-... (leave blank to use browser login)">
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
<input class="settings-input" id="input-http-port" type="text" placeholder="8080">
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
</div>
<div class="settings-row">
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
<input class="settings-input" id="input-socks-port" type="text" placeholder="1080">
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
</div>
<div class="settings-actions">
<span class="save-status" id="save-status"></span>
<button class="save-btn" id="save-btn">Save &amp; Restart</button>
</div>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
<div style="display:flex;gap:10px;align-items:center;">
<span id="log-count" class="log-badge"></span>
<button class="log-toggle" id="log-scroll-btn">Scroll to bottom</button>
</div>
</div>
<div class="log-box" id="log-box">Loading logs...</div>
</div>
<div class="refresh-bar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Auto-refresh every 5s</span>
</div>
<script>
(function() {
var APP = 'Tailscale_VPN';
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
var logBox = document.getElementById('log-box');
var autoScroll = true;
// Theme
var toggle = document.getElementById('themeToggle');
var sun = document.getElementById('iconSun');
var moon = document.getElementById('iconMoon');
var root = document.documentElement;
function applyTheme(t) {
if (t === 'light') {
root.setAttribute('data-theme', 'light');
sun.style.display = 'none';
moon.style.display = 'block';
} else {
root.removeAttribute('data-theme');
sun.style.display = 'block';
moon.style.display = 'none';
}
}
var stored = localStorage.getItem('ts-acap-theme');
if (stored) applyTheme(stored);
else if (window.matchMedia('(prefers-color-scheme: light)').matches) applyTheme('light');
toggle.addEventListener('click', function() {
var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
localStorage.setItem('ts-acap-theme', next);
applyTheme(next);
});
// Log scroll
document.getElementById('log-scroll-btn').addEventListener('click', function() {
logBox.scrollTop = logBox.scrollHeight;
autoScroll = true;
});
logBox.addEventListener('scroll', function() {
autoScroll = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 40;
});
// Cache helpers - survive syslog rotation
function cacheSet(k, v) { if (v) try { localStorage.setItem('ts-' + k, v); } catch(e){} }
function cacheGet(k) { try { return localStorage.getItem('ts-' + k); } catch(e){ return null; } }
function parse(txt) {
var allUrls = txt.match(/https:\/\/login\.tailscale\.com\/[^\s<"\t]+/g) || [];
var latestUrl = allUrls.length ? allUrls[allUrls.length - 1] : null;
var ipMatch = txt.match(/peerapi: serving on http:\/\/(100\.[\d.]+):/g);
var tsIP = null;
if (ipMatch) {
var last = ipMatch[ipMatch.length - 1];
var m = last.match(/http:\/\/(100\.[\d.]+):/);
if (m) tsIP = m[1];
}
if (!tsIP) {
var nmSelf = txt.match(/netmap: self:[^\n]*\[(100\.[\d.]+)\//);
if (nmSelf) tsIP = nmSelf[1];
}
if (!tsIP) {
var allIPs = txt.match(/\b100\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g) || [];
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
if (nodeMatches) {
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
if (nm) node = nm[1];
}
}
var loginMatches = txt.match(/active login:\s+\S+/g);
var tailnet = null;
if (loginMatches) {
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
if (lm) tailnet = lm[1];
}
if (!tailnet) {
// Fallback: extract from periodic netmap lines "u=user@email.com"
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
if (userMatches) {
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
if (um) tailnet = um[1];
}
}
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
var version = null;
if (versionMatches) {
var last = versionMatches[versionMatches.length - 1];
var vm = last.match(/v(\d+\.\d+\.\d+)/);
if (vm) version = vm[1];
}
if (!version) {
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
if (peersMatches) {
var lp = peersMatches[peersMatches.length - 1];
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
if (pm) version = pm[1];
}
}
// Parse proxy ports from log — use last match so old entries don't win
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
var socksPort = null;
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
tailnet = tailnet || cacheGet('tailnet');
version = version || cacheGet('version');
httpPort = httpPort || cacheGet('http-port');
socksPort = socksPort || cacheGet('socks-port');
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
// Fallbacks only when syslog has rotated and no state transitions are visible.
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
/derp-\d+ connected/.test(txt) ||
/c2n: GET/.test(txt) ||
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
// (handles stale Running entries in syslog after reinstall or token expiry)
if (isRunning && latestUrl) {
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
var urlSnippet = latestUrl.substring(0, 60);
var lastUrlIdx = -1, upos = 0, uidx;
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
if (lastUrlIdx > lastRunIdx) isRunning = false;
}
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version, httpPort: httpPort, socksPort: socksPort };
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version, httpPort: httpPort, socksPort: socksPort };
}
function classifyLine(msg) {
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
return '';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderLogs(txt) {
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
document.getElementById('log-count').textContent = lines.length + ' lines';
var h = '';
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
var cls = classifyLine(lines[i]);
if (parts) {
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
} else {
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
}
}
logBox.innerHTML = h;
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
}
function render(r) {
var banner = document.getElementById('status-banner');
var statusText = document.getElementById('status-text');
var auth = document.getElementById('auth-block');
var info = document.getElementById('info-card');
banner.className = 'status-banner ' + r.state;
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
statusText.textContent = labels[r.state];
if (r.state === 'connecting' && r.url) {
document.getElementById('auth-link').href = r.url;
document.getElementById('auth-url-text').textContent = r.url;
auth.style.display = '';
} else {
auth.style.display = 'none';
}
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
if (r.state === 'connected') {
document.getElementById('ts-ip').textContent = r.ip || '-';
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
document.getElementById('ts-node').textContent = r.node || '-';
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
document.getElementById('ts-version').textContent = r.version || '-';
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
info.style.display = '';
if (r.version) checkForUpdate(r.version);
} else {
info.style.display = 'none';
}
var now = new Date();
document.getElementById('status-time').textContent =
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
}
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
function checkAppRunning() {
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(xml) {
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
return m && m[1] === 'Running';
})
.catch(function() { return false; });
}
function refresh() {
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var result = parse(txt);
renderLogs(txt);
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (!result.url && result.state !== 'connected') {
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
result.tailnet = result.tailnet || cacheGet('tailnet');
result.version = result.version || cacheGet('version');
}
render(result);
});
})
.catch(function() {
document.getElementById('status-text').textContent = 'Unable to fetch logs';
});
}
refresh();
setInterval(refresh, 5000);
// Check for updates from GitHub
var installedVersion = null;
var autoChecked = false;
function checkForUpdate(currentVersion, manual) {
if (!currentVersion) return;
installedVersion = currentVersion;
if (!manual && autoChecked) return;
if (!manual) autoChecked = true;
var btn = document.getElementById('check-update-btn');
if (manual && btn) btn.textContent = 'Checking...';
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
.then(function(data) {
var tag = (data.tag_name || '').replace(/^v/, '');
if (!tag) return;
if (compareVersions(tag, currentVersion) > 0) {
document.getElementById('update-version').textContent = 'v' + tag;
document.getElementById('update-banner').classList.add('visible');
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
if (btn) btn.textContent = 'Update Available';
} else {
if (manual && btn) btn.textContent = 'Up to date';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
}
})
.catch(function() {
if (manual && btn) btn.textContent = 'Check failed';
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
});
}
document.getElementById('check-update-btn').addEventListener('click', function() {
if (installedVersion) checkForUpdate(installedVersion, true);
});
function compareVersions(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
// Settings — load current param values and save on submit
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
var httpPortInput = document.getElementById('input-http-port');
var socksPortInput= document.getElementById('input-socks-port');
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
function loadSettings() {
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(txt) {
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
if (hm) httpPortInput.value = hm[1].trim();
if (km) socksPortInput.value = km[1].trim();
// Update proxy display card with authoritative param values
// and overwrite the localStorage cache so stale ports don't win on next render
var httpPort = hm ? hm[1].trim() : null;
var socksPort = km ? km[1].trim() : null;
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
})
.catch(function() {});
}
function setStatus(msg, cls) {
saveStatus.textContent = msg;
saveStatus.className = 'save-status' + (cls ? ' ' + cls : '');
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
}
saveBtn.addEventListener('click', function() {
saveBtn.disabled = true;
setStatus('Saving...', '');
var httpPort = httpPortInput.value.trim() || '8080';
var socksPort = socksPortInput.value.trim() || '1080';
var params = 'action=update' +
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
fetch(PARAM_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
})
.then(function(r) { return r.text(); })
.then(function(txt) {
saveBtn.disabled = false;
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
} else {
setStatus('Error: ' + txt.trim(), 'err');
}
})
.catch(function(e) {
saveBtn.disabled = false;
setStatus('Failed to save', 'err');
});
});
loadSettings();
})();
</script>
</body>
</html>
View File
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+30 -52
View File
@@ -1,56 +1,34 @@
{
"schemaVersion": "1.7.0",
"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.102.4",
"architecture": "armv7hf"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
"schemaVersion": "1.7.0",
"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",
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
}
]
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
}
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
* Same structure as regular param_bridge.c but without proxy port params.
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
start_child();
}
}
return G_SOURCE_CONTINUE;
}
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
}
static AXParameter *g_ax_handle = NULL;
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting (root mode)");
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = { "CustomServer", "AuthKey" };
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+1 -5
View File
@@ -4,7 +4,7 @@ FROM axisecp/acap-sdk:3.5-armv7hf-ubuntu${UBUNTU_VERSION}
RUN apt-get update -qq && apt-get install -y --no-install-recommends upx-ucl && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY arm_acap3/app /opt/app/
COPY ./app /opt/app/
WORKDIR /opt/app
# Rename the shell startup script (the ELF launcher will take the Tailscale_VPN name)
@@ -30,10 +30,6 @@ RUN cp html/index.html index.html
# The log is written at runtime to localdata/ (resolved path at runtime).
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log
# Symlink the runtime status.json (written by start.sh from `tailscale status
# --json`) into html/ so the web UI can read Tailscale's authoritative state.
RUN ln -sf ../localdata/status.json html/status.json
# Build and package
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
+16 -42
View File
@@ -16,19 +16,19 @@ logger -t "Tailscale_VPN" "Starting tailscaled daemon"
# Log to file (not piped through logger) -- avoids extra logger process holding
# tailscaled stdout open, which prevents our wait loop from detecting exit
"$APP_DIR/lib/tailscaled" \
--state="$STATE_DIR/tailscaled.state" \
--socket="$STATE_DIR/tailscaled.sock" \
--socks5-server=localhost:1055 \
--outbound-http-proxy-listen=localhost:8080 \
--tun=userspace-networking \
>>"$STATE_DIR/tailscaled.log" 2>&1 &
--state="$STATE_DIR/tailscaled.state" \
--socket="$STATE_DIR/tailscaled.sock" \
--socks5-server=localhost:1055 \
--outbound-http-proxy-listen=localhost:8080 \
--tun=userspace-networking \
>> "$STATE_DIR/tailscaled.log" 2>&1 &
TAILSCALED_PID=$!
# Wait for socket to appear (up to 15 seconds)
i=0
while [ $i -lt 15 ] && [ ! -S "$STATE_DIR/tailscaled.sock" ]; do
sleep 1
i=$((i + 1))
sleep 1
i=$((i + 1))
done
logger -t "Tailscale_VPN" "Connecting to Tailscale network"
@@ -37,9 +37,9 @@ logger -t "Tailscale_VPN" "Connecting to Tailscale network"
# cameras with limited RAM (e.g. 222 MB).
# Capture output so we can extract auth URL and log it to syslog for the web UI.
UP_OUT=$("$APP_DIR/lib/tailscale" \
--socket="$STATE_DIR/tailscaled.sock" \
up --hostname="$(hostname)" --timeout=10s 2>&1) || true
echo "$UP_OUT" >>"$STATE_DIR/tailscaled.log"
--socket="$STATE_DIR/tailscaled.sock" \
up --hostname="$(hostname)" --timeout=10s 2>&1) || true
echo "$UP_OUT" >> "$STATE_DIR/tailscaled.log"
# If an auth URL was returned, log it so the web UI can show it
AUTH_URL=$(echo "$UP_OUT" | grep -o 'https://login\.tailscale\.com/[^ ]*' | head -1)
@@ -48,44 +48,18 @@ AUTH_URL=$(echo "$UP_OUT" | grep -o 'https://login\.tailscale\.com/[^ ]*' | head
# Log IP and version into syslog so the web UI details panel can populate
TS_IP=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" ip -4 2>/dev/null | head -1)
TS_VER=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" version 2>/dev/null | head -1)
[ -n "$TS_IP" ] && logger -t "Tailscale_VPN" "Tailscale IP: $TS_IP"
[ -n "$TS_IP" ] && logger -t "Tailscale_VPN" "Tailscale IP: $TS_IP"
[ -n "$TS_VER" ] && logger -t "Tailscale_VPN" "Tailscale version: $TS_VER"
logger -t "Tailscale_VPN" "Tailscale VPN is running"
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
# Publish tailscale's real backend state as JSON for the web UI to consume.
# This is the authoritative connection signal (BackendState / Self.Online /
# TailscaleIPs / AuthURL) instead of scraping logs, which otherwise reports
# "connected" whenever the launcher keeps the process alive (e.g. no Internet).
# Written to localdata and exposed at html/status.json via a build-time symlink.
STATUS_FILE="$STATE_DIR/status.json"
publish_status() {
if "$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" status --json >"$STATUS_FILE.tmp" 2>/dev/null; then
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
chmod 644 "$STATUS_FILE" 2>/dev/null
else
rm -f "$STATUS_FILE.tmp" 2>/dev/null
fi
}
# Remove stale status on stop so the UI does not show a connected node after exit.
cleanup() {
rm -f "$STATUS_FILE" 2>/dev/null
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
exit 0
}
trap cleanup TERM INT
# Monitoring loop: stay alive while tailscaled is running and keep the published
# status fresh. This keeps the parent Tailscale_VPN (C launcher) in the process
# table so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
# Monitoring loop: stay alive while tailscaled is running.
# This keeps the parent Tailscale_VPN (C launcher) in the process table
# so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
while kill -0 "$TAILSCALED_PID" 2>/dev/null; do
publish_status
sleep 5
sleep 5
done
rm -f "$STATUS_FILE" 2>/dev/null
logger -t "Tailscale_VPN" "tailscaled exited"
+19 -59
View File
@@ -11,7 +11,7 @@
--border: #262a35;
--text: #e4e6ed;
--muted: #8b8fa3;
--accent: #2e2d2d;
--accent: #4f8ff7;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
@@ -26,7 +26,7 @@
--border: #e0e3e8;
--text: #1a1a2e;
--muted: #6b7084;
--accent: #2e2d2d;
--accent: #2563eb;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -231,7 +231,7 @@
word-break: break-all;
}
.log-box .log-line { display: block; }
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
.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); }
@@ -257,8 +257,8 @@
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
background: rgba(46,45,45,0.1);
border: 1px solid rgba(46,45,45,0.2);
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 {
@@ -292,6 +292,18 @@
<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">
@@ -612,53 +624,6 @@
// ACAP3: also fetch the raw tailscaled.log (symlinked into html/) so the parser
// can find IP, version, tailnet and Running state from tailscaled's own output.
var DAEMON_LOG_URL = 'tailscaled.log';
// Authoritative backend state published by start.sh (symlinked into html/).
var STATUS_URL = 'status.json';
// Ground truth published by start.sh from `tailscale status --json`.
function fetchStatus() {
return fetch(STATUS_URL + '?t=' + Date.now(), { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.catch(function() { return null; });
}
// Apply Tailscale's authoritative backend state onto the result object.
function applyStatus(result, st) {
var self = st.Self || {};
var ips = self.TailscaleIPs || st.TailscaleIPs || [];
var ip4 = null;
for (var i = 0; i < ips.length; i++) { if (/^100\./.test(ips[i])) { ip4 = ips[i]; break; } }
var bs = st.BackendState;
if (st.Version) result.version = String(st.Version).split('-')[0];
if (bs === 'Running' && self.Online === true) {
// Genuinely connected and reachable on the tailnet
result.state = 'connected';
result.url = null;
result.ip = ip4 || result.ip;
result.node = self.HostName || result.node;
result.tailnet = (st.CurrentTailnet && st.CurrentTailnet.Name) || result.tailnet;
cacheSet('ip', result.ip); cacheSet('node', result.node);
cacheSet('tailnet', result.tailnet); cacheSet('version', result.version);
} else if (bs === 'NeedsLogin' || bs === 'NeedsMachineAuth') {
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Running') {
// Backend running but node not online: transient network drop or the
// node was removed/expired and needs re-auth. Not connected. Keep any
// login URL the log parser found so the login button still appears.
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Stopped') {
result.state = 'disconnected';
result.url = null;
} else {
// NoState / Starting / unknown
result.state = 'connecting';
result.url = st.AuthURL || result.url;
}
}
function refresh() {
var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
@@ -666,22 +631,17 @@
var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
.then(function(r) { return r.text(); }).catch(function() { return ''; });
Promise.all([syslogFetch, daemonFetch, fetchStatus()]).then(function(res) {
Promise.all([syslogFetch, daemonFetch]).then(function(res) {
// Syslog provides Axis timestamp headers (node name) and start/stop events.
// tailscaled.log provides IP, version, tailnet, and -> Running state.
var txt = res[0] + '\n' + res[1];
var st = res[2];
var result = parse(txt);
renderLogs(res[0]); // show syslog in log panel; daemon log is too verbose
// Always verify with the app status API - logs can have stale entries
// Always verify with the app status API - syslog can have stale entries
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
} else if (st && st.BackendState) {
// Authoritative: Tailscale's own backend state
applyStatus(result, st);
} else if (!result.url && result.state !== 'connected') {
// Fallback to log heuristic when status.json is unavailable
result.state = 'connected';
result.ip = result.ip || cacheGet('ip');
result.node = result.node || cacheGet('node');
+1 -1
View File
@@ -2,7 +2,7 @@ PACKAGENAME=Tailscale_VPN
MENUNAME="Tailscale VPN"
VENDOR="Mo3he"
APPMAJORVERSION=1
APPMINORVERSION=102
APPMINORVERSION=96
APPMICROVERSION=4
APPTYPE=armv7hf
APPNAME=Tailscale_VPN
-147
View File
@@ -1,147 +0,0 @@
#!/usr/bin/env sh
# Build the Tailscale ACAP variants.
#
# ./build.sh # build every variant
# ./build.sh aarch64 arm # build only the named variant folders
#
# Downloads the prebuilt Tailscale binaries, strips them, then builds each
# variant folder that contains an app/ directory. Variant folders map to the
# .eap suffixes used in releases: *_ROOT -> _root, *_acap3 -> _acap3.
#
# Override the container runtime with RUNTIME=docker|podman.
# TAILSCALE_VERSION pins the upstream binaries; it defaults to whatever
# ci/upstream-version.sh resolves.
set -eu
REPO_ROOT=$(cd -P "$(dirname "$0")" && pwd)
cd "$REPO_ROOT"
if [ -z "${RUNTIME:-}" ]; then
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
RUNTIME=docker
elif command -v podman >/dev/null 2>&1; then
RUNTIME=podman
else
echo 'Error: neither docker nor podman found in PATH' >&2
exit 1
fi
fi
echo "==> Using container runtime: ${RUNTIME}"
VERSION="${TAILSCALE_VERSION:-$(sh ci/upstream-version.sh)}"
[ -n "$VERSION" ] || {
echo 'Error: could not resolve a Tailscale version' >&2
exit 1
}
echo "==> Tailscale version: ${VERSION}"
# --- fetch and strip upstream binaries ---------------------------------------
BINS="${REPO_ROOT}/tailscale_bins"
rm -rf "$BINS"
rm -rf "${REPO_ROOT}/debug"
mkdir -p "$BINS"
fetch_arch() {
tgz_arch=$1
suffix=$2
echo "==> Downloading tailscale ${VERSION} (${tgz_arch})"
curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${VERSION}_${tgz_arch}.tgz" \
-o "${BINS}/ts_${suffix}.tgz"
tar -xzf "${BINS}/ts_${suffix}.tgz" -C "$BINS" --strip-components=1
mv "${BINS}/tailscale" "${BINS}/tailscale_${suffix}"
mv "${BINS}/tailscaled" "${BINS}/tailscaled_${suffix}"
rm -f "${BINS}/ts_${suffix}.tgz"
}
fetch_arch arm arm
fetch_arch arm64 arm64
# Tailscale is the only upstream here that ships binaries with symbols, so the
# strip is worth ~23 MB per package. It runs inside the SDK container: relying
# on host cross-binutils meant a machine without them silently produced an
# unstripped package under the same version number.
SDK_IMAGE=axisecp/acap-native-sdk:12.10.0
SDK_UBUNTU=ubuntu24.04
strip_arch() {
sdk_arch=$1
suffix=$2
echo "==> Stripping ${suffix} binaries"
# Unstripped copies for symbolising crash dumps; never shipped.
mkdir -p "${REPO_ROOT}/debug"
cp "${BINS}/tailscale_${suffix}" "${REPO_ROOT}/debug/tailscale-${suffix}.unstripped"
cp "${BINS}/tailscaled_${suffix}" "${REPO_ROOT}/debug/tailscaled-${suffix}.unstripped"
# SC2016: $STRIP must expand inside the container, not on the host.
# shellcheck disable=SC2016
cid=$("$RUNTIME" create "${SDK_IMAGE}-${sdk_arch}-${SDK_UBUNTU}" sh -c \
'. /opt/axis/acapsdk/environment-setup* >/dev/null 2>&1 && "${STRIP:?SDK environment did not set STRIP}" /tmp/tailscale /tmp/tailscaled')
"$RUNTIME" cp "${BINS}/tailscale_${suffix}" "${cid}:/tmp/tailscale"
"$RUNTIME" cp "${BINS}/tailscaled_${suffix}" "${cid}:/tmp/tailscaled"
"$RUNTIME" start -a "$cid"
"$RUNTIME" cp "${cid}:/tmp/tailscale" "${BINS}/tailscale_${suffix}"
"$RUNTIME" cp "${cid}:/tmp/tailscaled" "${BINS}/tailscaled_${suffix}"
"$RUNTIME" rm "$cid" >/dev/null
}
strip_arch aarch64 arm64
strip_arch armv7hf arm
# --- build variants -----------------------------------------------------------
echo '==> Cleaning old .eap files...'
rm -f "${REPO_ROOT}"/*.eap
rm -rf "${REPO_ROOT}/build"
build_variant() {
folder=${1%/}
[ -d "${folder}/app" ] || return 0
[ "$folder" = common ] && return 0
# aarch64/arm/aarch64_ROOT/arm_ROOT share sources via common/app; only
# arm_acap3 carries its own self-contained app tree.
case "$folder" in
aarch64 | arm | aarch64_ROOT | arm_ROOT) lib_dir="common/app/lib" ;;
*) lib_dir="${folder}/app/lib" ;;
esac
mkdir -p "$lib_dir"
case "$folder" in
arm*) src=arm ;;
*) src=arm64 ;;
esac
cp "${BINS}/tailscale_${src}" "${lib_dir}/tailscale"
cp "${BINS}/tailscaled_${src}" "${lib_dir}/tailscaled"
case "$folder" in
*_ROOT) variant="_root" ;;
*_acap3) variant="_acap3" ;;
*) variant="" ;;
esac
tag=$(echo "$folder" | tr '[:upper:]' '[:lower:]' | tr '/ ' '__')
echo "==> Building ${folder}"
"$RUNTIME" build -f "${folder}/Dockerfile" --tag "$tag" .
out="${REPO_ROOT}/build/${tag}"
mkdir -p "$out"
cid=$("$RUNTIME" create "$tag")
"$RUNTIME" cp "${cid}:/opt/app" "$out"
"$RUNTIME" rm "$cid" >/dev/null
find "$out" -type f -name '*.eap' | while read -r eap; do
base=$(basename "$eap" .eap)
mv "$eap" "${REPO_ROOT}/${base}${variant}.eap"
done
}
if [ "$#" -eq 0 ]; then
set -- */
fi
for v in "$@"; do
build_variant "$v"
done
rm -rf "${REPO_ROOT}/build" "$BINS"
echo '==> Done!'
ls -lh "${REPO_ROOT}"/*.eap 2>/dev/null || true
-113
View File
@@ -1,113 +0,0 @@
#!/usr/bin/env bash
#
# Write a version into every place this repo records it and refresh the
# upstream pins declared in .acap.json.
#
# Usage: ci/apply-version.sh <version> [upstream-version]
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION=${1:?version required}
UPSTREAM=${2:-}
CONFIG=.acap.json
cfg() { jq -r "$1" "$CONFIG"; }
IFS='.' read -r MAJOR MINOR MICRO <<<"$VERSION"
while IFS= read -r manifest; do
[ -n "$manifest" ] || continue
tmp=$(mktemp)
jq --arg v "$VERSION" '.acapPackageConf.setup.version = $v' "$manifest" >"$tmp"
mv "$tmp" "$manifest"
echo "version $VERSION -> $manifest"
done < <(find . -path '*/app/manifest.json' -not -path './node_modules/*' | sort)
while IFS= read -r conf; do
[ -n "$conf" ] || continue
sed -i.bak -E \
-e "s/^APPMAJORVERSION=.*/APPMAJORVERSION=${MAJOR}/" \
-e "s/^APPMINORVERSION=.*/APPMINORVERSION=${MINOR}/" \
-e "s/^APPMICROVERSION=.*/APPMICROVERSION=${MICRO}/" \
-e "s/^VERSION=.*/VERSION=${VERSION}/" \
"$conf"
rm -f "$conf.bak"
echo "version $VERSION -> $conf"
done < <(find . -path '*/app/package.conf' | sort)
pin_count=$(cfg '.pins | length')
for ((i = 0; i < pin_count; i++)); do
file=$(cfg ".pins[$i].file")
arg=$(cfg ".pins[$i].arg")
prefix=$(cfg ".pins[$i].prefix // empty")
sha_url=$(cfg ".pins[$i].sha256Url // empty")
gomodule=$(cfg ".pins[$i].goModule // empty")
[ -f "$file" ] || {
echo "pin target missing: $file" >&2
continue
}
# A go.mod pin has no ARG to substitute, and go refuses a bare "0.77.1", so
# the declared prefix has to be applied here.
if [ -n "$gomodule" ]; then
(cd "$(dirname "$file")" && go get "${gomodule}@${prefix}${UPSTREAM:-$VERSION}" && go mod tidy)
echo "go module ${gomodule}@${prefix}${UPSTREAM:-$VERSION} -> $file"
continue
fi
if [ -n "$sha_url" ]; then
# Checksum pins track the version pin, so the tarball is fetched and
# hashed rather than substituted.
url=${sha_url//\$\{VERSION\}/${UPSTREAM:-$VERSION}}
echo "hashing $url"
value=$(curl -fsSL "$url" | sha256sum | awk '{print $1}')
else
value="${prefix}${UPSTREAM:-$VERSION}"
fi
sed -i.bak -E "s|^ARG ${arg}=.*|ARG ${arg}=${value}|" "$file"
rm -f "$file.bak"
echo "pin ${arg}=${value} -> $file"
done
module=$(cfg '.upstream.module // empty')
gomod=$(cfg '.upstream.goMod // empty')
if [ -n "$module" ] && [ -n "$UPSTREAM" ] && [ -f "$gomod" ]; then
(cd "$(dirname "$gomod")" && go get "${module}@${UPSTREAM}" && go mod tidy)
echo "go module ${module}@${UPSTREAM}"
fi
# Web UIs compare the installed version against the latest GitHub release. The
# literal is marked so it cannot drift out of sync with the manifest.
while IFS= read -r page; do
[ -n "$page" ] || continue
sed -i.bak -E "s|'[0-9]+\.[0-9]+\.[0-9]+'( /\* acap:installed-version \*/)|'${VERSION}'\1|g" "$page"
rm -f "$page.bak"
echo "installed-version $VERSION -> $page"
done < <(grep -rl 'acap:installed-version' --include='*.html' . 2>/dev/null || true)
if [ -f CHANGELOG.md ] && ! grep -qE "^## \[?${VERSION}\]?" CHANGELOG.md; then
first_heading=$(grep -n -m1 '^## ' CHANGELOG.md | cut -d: -f1 || true)
tmp=$(mktemp)
{
if [ -n "$first_heading" ]; then
head -n "$((first_heading - 1))" CHANGELOG.md
else
cat CHANGELOG.md
echo
fi
echo "## ${VERSION} - $(date +%Y-%m-%d)"
echo
if [ -n "$UPSTREAM" ]; then
echo "- Update to upstream ${UPSTREAM}."
else
echo "- Release ${VERSION}."
fi
echo
[ -n "$first_heading" ] && tail -n +"$first_heading" CHANGELOG.md
} >"$tmp"
mv "$tmp" CHANGELOG.md
echo "changelog entry added for $VERSION"
fi
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env bash
#
# Run the repo's build and collect every .eap into releases/.
# The build command and extra env come from .acap.json.
set -euo pipefail
cd "$(dirname "$0")/.."
CONFIG=.acap.json
cfg() { jq -r "$1" "$CONFIG"; }
COMMAND=$(cfg '.build.command')
while IFS=$'\t' read -r key value; do
[ -n "$key" ] || continue
value=${value//\$\{VERSION\}/${VERSION:-}}
export "$key=$value"
echo "env $key=$value"
done < <(cfg '.build.env | to_entries[]? | [.key, .value] | @tsv')
rm -rf releases
mkdir -p releases
echo "== $COMMAND"
eval "$COMMAND"
# Repos drop packages in the root, build/, build_<arch>/ or straight into
# releases/ depending on the repo, so gather any strays and then count what
# actually ended up in releases/.
while IFS= read -r package; do
[ -n "$package" ] || continue
mv "$package" releases/
done < <(find . -name '*.eap' -not -path './releases/*' -not -path './.git/*')
found=$(find releases -name '*.eap' | wc -l | tr -d ' ')
[ "$found" -gt 0 ] || {
echo "no .eap produced" >&2
exit 1
}
echo "collected $found package(s):"
ls -lh releases/
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
#
# Generate release notes for a draft release.
#
# Usage: ci/release-notes.sh <version> [upstream-version] > notes.md
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION=${1:?version required}
UPSTREAM=${2:-}
CONFIG=.acap.json
cfg() { jq -r "$1" "$CONFIG"; }
FRIENDLY=$(cfg '.friendlyName')
UPSTREAM_NAME=$(cfg '.upstream.name // .upstream.repo // .upstream.module // empty')
CHANGES_URL=$(cfg '.upstream.changesUrl // empty')
CHANGES_URL=${CHANGES_URL//\$\{UPSTREAM\}/$UPSTREAM}
# Previous tag, so the compare link points somewhere useful.
PREVIOUS=$(git tag --list 'v*' --sort=-v:refname | grep -v "^v${VERSION}$" | head -1 || true)
printf '%s %s\n\n' "$FRIENDLY" "$VERSION"
if [ -n "$UPSTREAM" ] && [ -n "$UPSTREAM_NAME" ]; then
printf 'Packages **%s `%s`**.\n\n' "$UPSTREAM_NAME" "$UPSTREAM"
fi
if [ -n "$CHANGES_URL" ]; then
printf '### Upstream changes\n\n%s\n\n' "$CHANGES_URL"
fi
if [ -f CHANGELOG.md ]; then
# Pull just this version's section out of the changelog.
section=$(awk -v v="$VERSION" '
$0 ~ "^## \\[?" v "\\]?" { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed '/^[[:space:]]*$/d')
if [ -n "$section" ]; then
printf '### Changes\n\n%s\n\n' "$section"
fi
fi
printf '### Packages\n\n'
printf 'Install the `signed_*.eap` matching your device architecture.\n\n'
# Only explain the unsigned variants when this release actually ships them.
unsigned_note=''
if compgen -G 'releases/*_acap3.eap' >/dev/null 2>&1; then
unsigned_note='`_acap3`'
fi
if compgen -G 'releases/*_root.eap' >/dev/null 2>&1; then
[ -n "$unsigned_note" ] && unsigned_note="${unsigned_note} and "
unsigned_note="${unsigned_note}\`_root\`"
fi
if [ -n "$unsigned_note" ]; then
printf 'Packages ending %s are published unsigned by design:\n' "$unsigned_note"
printf 'they use manifest schema 1.x, which the Axis signing service does not accept.\n\n'
fi
if [ -n "$PREVIOUS" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then
printf '**Full changelog**: https://github.com/%s/compare/%s...v%s\n' \
"$GITHUB_REPOSITORY" "$PREVIOUS" "$VERSION"
fi
-171
View File
@@ -1,171 +0,0 @@
#!/usr/bin/env bash
#
# Decide which version this repo should build, from .acap.json.
# Writes build/release/version/upstream to GITHUB_OUTPUT under CI, and always
# prints the decision so it can be run locally to preview.
#
# Policies:
# mirror the ACAP version follows the upstream version exactly.
# patch upstream is tracked through a pin; our own last digit is bumped.
set -euo pipefail
cd "$(dirname "$0")/.."
CONFIG=.acap.json
[ -f "$CONFIG" ] || {
echo "missing $CONFIG" >&2
exit 1
}
cfg() { jq -r "$1" "$CONFIG"; }
POLICY=$(cfg '.versionPolicy')
UPSTREAM_TYPE=$(cfg '.upstream.type')
EVENT_NAME=${EVENT_NAME:-manual}
INPUT_VERSION=${INPUT_VERSION:-}
INPUT_FORCE=${INPUT_FORCE:-false}
current_version() {
local manifest conf
manifest=$(find . -path '*/app/manifest.json' -not -path './node_modules/*' | sort | head -1)
if [ -n "$manifest" ]; then
jq -r '.acapPackageConf.setup.version' "$manifest"
return
fi
conf=$(find . -path '*/app/package.conf' | sort | head -1)
[ -n "$conf" ] && sed -n 's/^VERSION=//p' "$conf" | head -1
}
# Current value of the first pin, used by "patch" to detect upstream movement.
pin_value() {
local file arg gomod module
file=$(cfg '.pins[0].file // empty')
arg=$(cfg '.pins[0].arg // empty')
if [ -n "$file" ] && [ -n "$arg" ] && [ -f "$file" ]; then
sed -n "s/^ARG ${arg}=//p" "$file" | head -1
return
fi
gomod=$(cfg '.upstream.goMod // empty')
module=$(cfg '.upstream.module // empty')
if [ -n "$gomod" ] && [ -f "$gomod" ]; then
# The module may appear as "require mod ver" or as "mod ver" inside a
# require block, so take the field after the module name wherever it is.
awk -v m="$module" '{ for (i = 1; i < NF; i++) if ($i == m) { print $(i + 1); exit } }' "$gomod"
fi
}
# FFmpeg and openvpn3 publish no releases, and their tag lists contain names
# that are not versions, hence the explicit pattern per repo.
upstream_version() {
case "$UPSTREAM_TYPE" in
github-release)
local tag
tag=$(gh api "repos/$(cfg '.upstream.repo')/releases/latest" --jq '.tag_name')
[ "$(cfg '.upstream.stripV // false')" = true ] && tag=${tag#v}
printf '%s\n' "$tag"
;;
github-tag)
gh api "repos/$(cfg '.upstream.repo')/tags?per_page=100" --paginate --jq '.[].name' |
grep -E "$(cfg '.upstream.tagPattern')" |
sed "s|^$(cfg '.upstream.strip // empty')||" |
sort -V | tail -1
;;
go-module)
curl -fsSL "https://proxy.golang.org/$(cfg '.upstream.module')/@latest" | jq -r '.Version'
;;
script)
bash "$(cfg '.upstream.script')"
;;
*)
echo ''
;;
esac
}
bump_patch() {
local major minor patch
IFS='.' read -r major minor patch <<<"$1"
printf '%s.%s.%s\n' "${major:-0}" "${minor:-0}" "$((${patch:-0} + 1))"
}
# True when $1 is a strictly higher version than $2.
version_gt() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ]
}
CURRENT=$(current_version)
UPSTREAM=$(upstream_version || true)
BUILD=false
RELEASE=false
TARGET="$CURRENT"
if [ -n "$INPUT_VERSION" ]; then
TARGET=${INPUT_VERSION#v}
BUILD=true
RELEASE=true
elif [ "$POLICY" = mirror ]; then
if [ -n "$UPSTREAM" ] && [ "$UPSTREAM" != "$CURRENT" ]; then
if version_gt "$UPSTREAM" "$CURRENT"; then
# Upstream is ahead: adopt its version.
TARGET="$UPSTREAM"
BUILD=true
RELEASE=true
elif [ "$UPSTREAM" != "$(pin_value)" ]; then
# Our line already ran past upstream, so keep moving forward on it
# rather than emitting a lower version that clashes with old tags.
TARGET=$(bump_patch "$CURRENT")
BUILD=true
RELEASE=true
fi
fi
elif [ "$POLICY" = patch ]; then
if [ -n "$UPSTREAM" ] && [ "$UPSTREAM" != "$(pin_value)" ]; then
TARGET=$(bump_patch "$CURRENT")
BUILD=true
RELEASE=true
fi
fi
# Pull requests build for validation but never release.
if [ "$EVENT_NAME" = pull_request ]; then
BUILD=true
RELEASE=false
fi
if [ "$INPUT_FORCE" = true ]; then
BUILD=true
RELEASE=true
fi
# Never aim at a version that is already published. Stepping forward here means
# a long build is not wasted only to be rejected by the release job. An explicit
# version input is respected as given.
if [ "$RELEASE" = true ] && [ -z "$INPUT_VERSION" ] && command -v gh >/dev/null 2>&1; then
attempts=0
while [ "$attempts" -lt 20 ] &&
gh release view "v$TARGET" --json isDraft --jq '.isDraft' 2>/dev/null | grep -qx false; do
echo "v$TARGET is already published; stepping forward"
TARGET=$(bump_patch "$TARGET")
attempts=$((attempts + 1))
done
fi
cat <<EOF
policy : $POLICY
current : $CURRENT
upstream : ${UPSTREAM:-n/a}
target : $TARGET
build : $BUILD
release : $RELEASE
EOF
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "build=$BUILD"
echo "release=$RELEASE"
echo "version=$TARGET"
echo "upstream=$UPSTREAM"
} >>"$GITHUB_OUTPUT"
fi
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env sh
# Resolve the Tailscale version to package.
#
# Tailscale's GitHub "latest" release sometimes lands before the static ARM
# tarballs are published, so fall back to the newest version that actually has
# an ARM package on pkgs.tailscale.com.
set -eu
GH_VERSION=$(curl -fsS https://api.github.com/repos/tailscale/tailscale/releases/latest |
sed -n 's/.*"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p' | head -1)
if [ -n "${GH_VERSION}" ] &&
curl -sfI "https://pkgs.tailscale.com/stable/tailscale_${GH_VERSION}_arm.tgz" >/dev/null 2>&1; then
printf '%s\n' "${GH_VERSION}"
exit 0
fi
curl -fsS https://pkgs.tailscale.com/stable/ |
grep -o 'tailscale_[0-9.]*_arm\.tgz' |
sed -E 's/^tailscale_([0-9.]+)_arm\.tgz$/\1/' |
sort -V | tail -1
-177
View File
@@ -1,177 +0,0 @@
#!/bin/sh
# Tailscale VPN run script — called by the param_bridge C binary.
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
# $1 selects the variant: "standard" (userspace networking + local proxies)
# or "root" (kernel networking, no local proxy). Defaults to "standard".
VARIANT="${1:-standard}"
killall tailscaled 2>/dev/null || true
APP_DIR="/usr/local/packages/Tailscale_VPN"
STATE_DIR="$APP_DIR/localdata"
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
mkdir -p "$STATE_DIR"
chmod 755 $TAILSCALED_PATH
chmod 755 $TAILSCALE_PATH
# Defaults — overridden by sourcing params.conf written by param_bridge
CUSTOM_SERVER=""
AUTH_KEY=""
CONF_HTTP="8080"
CONF_SOCKS="1080"
ACCEPT_DNS="false"
ACCEPT_ROUTES="false"
ADVERTISE_ROUTES=""
if [ -f "$STATE_DIR/params.conf" ]; then
. "$STATE_DIR/params.conf"
fi
if [ "$VARIANT" = "root" ]; then
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
else
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
# Check whether a TCP port is already bound
is_port_in_use() {
local port=$1
local hex_port
hex_port=$(printf '%04X' "$port")
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
return 1
}
if is_port_in_use "$CONF_HTTP"; then
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
exit 1
fi
if is_port_in_use "$CONF_SOCKS"; then
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
exit 1
fi
fi
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
if [ "$VARIANT" = "root" ]; then
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
>/dev/null 2>&1 &
else
$TAILSCALED_PATH \
--state="$STATE_DIR/tailscaled.state" \
--socket=$SOCKET_PATH \
--socks5-server=localhost:$CONF_SOCKS \
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
--tun=userspace-networking \
>/dev/null 2>&1 &
fi
TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
fi
if [ "$ACCEPT_DNS" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
fi
if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
fi
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In userspace-networking mode the
# tailscaled netstack forwards tailnet traffic to these subnets, so no kernel IP
# forwarding is required. In kernel-networking (root) mode the host must
# forward packets between the tailnet and the LAN, so enable IP forwarding.
# Routes must still be approved in the Tailscale admin console either way.
if [ -n "$ADVERTISE_ROUTES" ]; then
if [ "$VARIANT" = "root" ]; then
echo 1 >/proc/sys/net/ipv4/ip_forward 2>/dev/null || true
echo 1 >/proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
fi
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
fi
# Run `tailscale up` in the background and act on its outcome. If the node needs
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
# ensures the status publisher below keeps running so the UI can surface the
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
# code is captured directly. We must NOT background `up` separately and `wait`
# for it from here, because in POSIX sh `wait` only works on children of the
# current shell — a subshell waiting on the parent's child returns 127.
{
eval "$TAILSCALE_CMD"
up_exit=$?
if [ "$up_exit" -eq 0 ]; then
if [ "$VARIANT" = "root" ]; then
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
else
logger -t "Tailscale_VPN" "Tailscale VPN is running"
fi
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
if [ -n "$AUTH_KEY" ]; then
: >"$STATE_DIR/authkey_clear"
fi
else
logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)"
fi
} &
TAILSCALE_UP_PID=$!
if [ "$VARIANT" != "root" ]; then
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS"
fi
# Publish tailscale's real backend state as JSON for the web UI to consume.
# This is the authoritative connection signal (BackendState / Self.Online /
# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at
# /local/Tailscale_VPN/status.json.
STATUS_FILE="$APP_DIR/html/status.json"
publish_status() {
if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json >"$STATUS_FILE.tmp" 2>/dev/null; then
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
chmod 644 "$STATUS_FILE" 2>/dev/null
else
rm -f "$STATUS_FILE.tmp" 2>/dev/null
fi
}
status_loop() {
while true; do
publish_status
sleep 5
done
}
status_loop &
STATUS_LOOP_PID=$!
# Clean up the status writer, up watcher, daemon and published status on
# stop/restart so param_bridge (which signals this script) leaves no orphans or
# stale state.
cleanup() {
[ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null
[ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
rm -f "$STATUS_FILE" 2>/dev/null
exit 0
}
trap cleanup TERM INT
wait $TAILSCALED_PID
-586
View File
@@ -1,586 +0,0 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN.
*
* Responsibilities:
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
* 2. Write them to CONFIG_FILE so the shell script can source them.
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
* of the child so the new config is picked up.
* Rapid changes within 300 ms are coalesced into a single restart.
* 5. Watchdog: if the child exits unexpectedly, restart it.
*
* Shared across the userspace-networking variants (unprivileged 'sdk' ACAP
* user) and the ROOT / kernel-networking variant. Build with -DHAS_PROXY_PORTS
* for the userspace variants, which exposes the HTTP/SOCKS5 proxy port
* parameters; the ROOT variant omits them since it has no local proxy.
*/
#include <axsdk/axparameter.h>
#include <glib-unix.h>
#include <gio/gio.h>
#include <stdbool.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#define APP_NAME "Tailscale_VPN"
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
#define SENTINEL_FILE "/usr/local/packages/Tailscale_VPN/localdata/authkey_clear"
#ifdef HAS_PROXY_PORTS
#define RUN_SCRIPT_VARIANT "standard"
#else
#define RUN_SCRIPT_VARIANT "root"
#endif
static AXParameter *g_ax_handle = NULL;
static pid_t child_pid = -1;
static guint reload_timer_id = 0;
static char *cfg_custom_server = NULL;
static char *cfg_auth_key = NULL;
#ifdef HAS_PROXY_PORTS
static char *cfg_http_proxy_port = NULL;
static char *cfg_socks5_port = NULL;
#endif
static char *cfg_accept_dns = NULL;
static char *cfg_accept_routes = NULL;
static char *cfg_advertise_routes = NULL;
static void cache_set(char **field, const char *value) {
if (!value) return;
free(*field);
*field = strdup(value);
}
static const char *cache_get(char **field, const char *fallback) {
return (*field && **field) ? *field : fallback;
}
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
* upgrades a newly introduced manifest parameter is not always auto-registered,
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
GError *err = NULL;
if (!ax_parameter_add(handle, name, def, "string", &err)) {
if (err) g_error_free(err);
}
}
/* ── child process management ──────────────────────────────────────────── */
static void stop_child(void) {
if (child_pid <= 0)
return;
kill(child_pid, SIGTERM);
for (int i = 0; i < 30; i++) {
int status;
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
child_pid = -1;
return;
}
usleep(100000);
}
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
kill(child_pid, SIGKILL);
waitpid(child_pid, NULL, 0);
child_pid = -1;
}
static void start_child(void) {
stop_child();
pid_t pid = fork();
if (pid < 0) {
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
return;
}
if (pid == 0) {
execl(RUN_SCRIPT, RUN_SCRIPT, RUN_SCRIPT_VARIANT, NULL);
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
_exit(1);
}
child_pid = pid;
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
}
/* ── watchdog ────────────────────────────────────────────────────────────── */
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
if (child_pid > 0) {
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == child_pid) {
int exit_code = WEXITSTATUS(status);
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
child_pid = -1;
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
start_child();
}
}
return G_SOURCE_CONTINUE;
}
/* ── auth-key sentinel ───────────────────────────────────────────────────── */
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
* disappears from the settings UI. This replaces the old exit-code-0 path,
* which never fired because tailscaled keeps the child alive indefinitely. */
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
if (access(SENTINEL_FILE, F_OK) != 0)
return G_SOURCE_CONTINUE;
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
free(cfg_auth_key); cfg_auth_key = strdup("");
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
} else {
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
unlink(SENTINEL_FILE);
return G_SOURCE_CONTINUE;
}
/* ── config file ─────────────────────────────────────────────────────────── */
static void load_config_cache(AXParameter *handle) {
GError *error = NULL;
gchar *val = NULL;
#define LOAD(name, field) \
val = NULL; error = NULL; \
if (ax_parameter_get(handle, name, &val, &error)) { \
free(field); field = val ? strdup(val) : strdup(""); \
g_free(val); val = NULL; \
} else { \
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
error ? error->message : "unknown"); \
if (error) { g_error_free(error); error = NULL; } \
}
LOAD("CustomServer", cfg_custom_server)
LOAD("AuthKey", cfg_auth_key)
#ifdef HAS_PROXY_PORTS
LOAD("HttpProxyPort", cfg_http_proxy_port)
LOAD("Socks5Port", cfg_socks5_port)
#endif
LOAD("AcceptDNS", cfg_accept_dns)
LOAD("AcceptRoutes", cfg_accept_routes)
LOAD("AdvertiseRoutes", cfg_advertise_routes)
#undef LOAD
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
syslog(LOG_ERR, "cannot open config file %s: %s",
CONFIG_FILE, strerror(errno));
return;
}
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
#ifdef HAS_PROXY_PORTS
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
#endif
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
#ifdef HAS_PROXY_PORTS
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
cache_get(&cfg_http_proxy_port, "8080"),
cache_get(&cfg_socks5_port, "1080"),
cache_get(&cfg_custom_server, "(default)"));
#else
syslog(LOG_INFO, "config updated: server=%s",
cache_get(&cfg_custom_server, "(default)"));
#endif
}
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
reload_timer_id = 0;
if (g_ax_handle)
load_config_cache(g_ax_handle);
write_config_file();
syslog(LOG_INFO, "restarting with new config");
stop_child();
start_child();
return G_SOURCE_REMOVE;
}
static void parameter_changed(const gchar *name, const gchar *value,
gpointer G_GNUC_UNUSED handle_void_ptr) {
const char *dot = strrchr(name, '.');
const char *short_name = dot ? dot + 1 : name;
syslog(LOG_INFO, "parameter changed: %s", short_name);
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
#ifdef HAS_PROXY_PORTS
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
#endif
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
if (reload_timer_id)
g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
}
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
* settings through it. This tiny HTTP server, reached through the manifest
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
* fall back to reading and writing the parameters directly. */
#define HTTP_PORT 2201
static const char *http_param_names[] = {
"CustomServer", "AuthKey",
#ifdef HAS_PROXY_PORTS
"HttpProxyPort", "Socks5Port",
#endif
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
static void cache_set_by_name(const char *name, const char *value) {
if (strcmp(name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
else if (strcmp(name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
#ifdef HAS_PROXY_PORTS
else if (strcmp(name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
else if (strcmp(name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
#endif
else if (strcmp(name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
else if (strcmp(name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
else if (strcmp(name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
}
static int http_is_known_param(const char *name) {
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++)
if (strcmp(name, http_param_names[i]) == 0) return 1;
return 0;
}
static void http_json_append_escaped(GString *out, const char *s) {
for (const char *p = s; *p; p++) {
switch (*p) {
case '"': g_string_append(out, "\\\""); break;
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
case '\t': g_string_append(out, "\\t"); break;
default:
if ((unsigned char)*p < 0x20)
g_string_append_printf(out, "\\u%04x", (unsigned char)*p);
else
g_string_append_c(out, *p);
}
}
}
static gchar *http_build_settings_json(AXParameter *handle) {
GString *out = g_string_new("{");
for (size_t i = 0; i < G_N_ELEMENTS(http_param_names); i++) {
gchar *val = NULL;
GError *err = NULL;
if (!ax_parameter_get(handle, http_param_names[i], &val, &err)) {
if (err) g_error_free(err);
val = g_strdup("");
}
if (i) g_string_append_c(out, ',');
g_string_append_printf(out, "\"%s\":\"", http_param_names[i]);
http_json_append_escaped(out, val ? val : "");
g_string_append_c(out, '"');
g_free(val);
}
g_string_append_c(out, '}');
/* g_string_free(out, FALSE) is inlined by glib >= 2.76 headers into a call
* to g_string_free_and_steal(), which doesn't exist in older glib runtimes
* (e.g. AXIS OS 11.x). Copy out and fully free instead to stay portable. */
gchar *json_result = g_strdup(out->str);
g_string_free(out, TRUE);
return json_result;
}
static gchar *http_url_decode(const char *s, size_t len) {
GString *out = g_string_new(NULL);
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (c == '+') {
g_string_append_c(out, ' ');
} else if (c == '%' && i + 2 < len &&
g_ascii_isxdigit(s[i + 1]) && g_ascii_isxdigit(s[i + 2])) {
int hi = g_ascii_xdigit_value(s[i + 1]);
int lo = g_ascii_xdigit_value(s[i + 2]);
g_string_append_c(out, (char)((hi << 4) | lo));
i += 2;
} else {
g_string_append_c(out, c);
}
}
gchar *decoded_result = g_strdup(out->str);
g_string_free(out, TRUE);
return decoded_result;
}
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
* the parameter store. Returns the number of parameters successfully set. */
static int http_apply_settings(AXParameter *handle, const char *body, size_t len) {
int applied = 0;
size_t start = 0;
for (size_t i = 0; i <= len; i++) {
if (i == len || body[i] == '&') {
size_t seg_len = i - start;
if (seg_len > 0) {
const char *seg = body + start;
const char *eq = memchr(seg, '=', seg_len);
if (eq) {
size_t nlen = (size_t)(eq - seg);
gchar *name = g_strndup(seg, nlen);
gchar *value = http_url_decode(eq + 1, seg_len - nlen - 1);
if (http_is_known_param(name)) {
GError *err = NULL;
if (ax_parameter_set(handle, name, value, TRUE, &err)) {
cache_set_by_name(name, value);
applied++;
} else {
syslog(LOG_WARNING, "http set %s failed: %s",
name, err ? err->message : "unknown");
if (err) g_error_free(err);
}
}
g_free(name);
g_free(value);
}
}
start = i + 1;
}
}
return applied;
}
static size_t http_parse_content_length(const char *hdr, size_t hlen) {
const char *key = "content-length:";
size_t klen = strlen(key);
for (size_t i = 0; i + klen <= hlen; i++) {
if (g_ascii_strncasecmp(hdr + i, key, klen) == 0) {
i += klen;
while (i < hlen && (hdr[i] == ' ' || hdr[i] == '\t')) i++;
return (size_t)strtoul(hdr + i, NULL, 10);
}
}
return 0;
}
static void http_send(GOutputStream *out, const char *status,
const char *ctype, const char *body) {
gchar *resp = g_strdup_printf(
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
status, ctype, strlen(body), body);
g_output_stream_write_all(out, resp, strlen(resp), NULL, NULL, NULL);
g_free(resp);
}
static gboolean http_on_incoming(GSocketService *service G_GNUC_UNUSED,
GSocketConnection *connection,
GObject *source G_GNUC_UNUSED,
gpointer user_data) {
AXParameter *handle = (AXParameter *)user_data;
GInputStream *in = g_io_stream_get_input_stream(G_IO_STREAM(connection));
GOutputStream *out = g_io_stream_get_output_stream(G_IO_STREAM(connection));
GString *req = g_string_new(NULL);
char buf[2048];
int have_headers = 0;
size_t header_end = 0;
size_t content_length = 0;
while (1) {
gssize n = g_input_stream_read(in, buf, sizeof(buf), NULL, NULL);
if (n <= 0) break;
g_string_append_len(req, buf, n);
if (!have_headers) {
char *p = g_strstr_len(req->str, req->len, "\r\n\r\n");
if (p) {
have_headers = 1;
header_end = (size_t)(p - req->str) + 4;
content_length = http_parse_content_length(req->str, header_end);
}
}
if (have_headers && req->len - header_end >= content_length) break;
if (req->len > 262144) break; /* safety cap */
}
int is_get = 0, is_post = 0, is_settings = 0;
if (have_headers) {
if (g_str_has_prefix(req->str, "GET ")) is_get = 1;
if (g_str_has_prefix(req->str, "POST ")) is_post = 1;
const char *sp1 = strchr(req->str, ' ');
if (sp1) {
const char *path = sp1 + 1;
const char *sp2 = strchr(path, ' ');
size_t plen = sp2 ? (size_t)(sp2 - path) : strlen(path);
const char *q = memchr(path, '?', plen);
size_t match_len = q ? (size_t)(q - path) : plen;
if (match_len >= 8 &&
g_ascii_strncasecmp(path + match_len - 8, "settings", 8) == 0)
is_settings = 1;
}
}
if (is_settings && is_get) {
gchar *json = http_build_settings_json(handle);
http_send(out, "200 OK", "application/json", json);
g_free(json);
} else if (is_settings && is_post) {
const char *body = req->str + header_end;
size_t body_len = req->len - header_end;
if (body_len > content_length) body_len = content_length;
int applied = http_apply_settings(handle, body, body_len);
syslog(LOG_INFO, "settings http: applied %d parameter(s)", applied);
if (reload_timer_id) g_source_remove(reload_timer_id);
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
http_send(out, "200 OK", "text/plain", "OK");
} else {
http_send(out, "404 Not Found", "text/plain", "Not found");
}
g_string_free(req, TRUE);
g_io_stream_close(G_IO_STREAM(connection), NULL, NULL);
return TRUE;
}
static void http_server_start(AXParameter *handle) {
GError *err = NULL;
GSocketService *service = g_socket_service_new();
GInetAddress *addr = g_inet_address_new_from_string("127.0.0.1");
GSocketAddress *saddr = g_inet_socket_address_new(addr, HTTP_PORT);
if (!g_socket_listener_add_address(G_SOCKET_LISTENER(service), saddr,
G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP,
NULL, NULL, &err)) {
syslog(LOG_WARNING, "settings http: bind 127.0.0.1:%d failed: %s",
HTTP_PORT, err ? err->message : "unknown");
if (err) g_error_free(err);
g_object_unref(service);
} else {
g_signal_connect(service, "incoming", G_CALLBACK(http_on_incoming), handle);
g_socket_service_start(service);
syslog(LOG_INFO, "settings http server listening on 127.0.0.1:%d", HTTP_PORT);
}
g_object_unref(addr);
g_object_unref(saddr);
}
/* ── signal handler ──────────────────────────────────────────────────────── */
static gboolean signal_handler(gpointer loop) {
syslog(LOG_INFO, "stopping");
stop_child();
g_main_loop_quit((GMainLoop *)loop);
return G_SOURCE_REMOVE;
}
/* ── main ────────────────────────────────────────────────────────────────── */
int main(void) {
GError *error = NULL;
openlog(APP_NAME, LOG_PID, LOG_USER);
syslog(LOG_INFO, "starting");
/* Ensure localdata dir exists */
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
* freshly configured key before it has been used. */
unlink(SENTINEL_FILE);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
if (!handle) {
syslog(LOG_ERR, "ax_parameter_new: %s",
error ? error->message : "unknown");
if (error) g_error_free(error);
return 1;
}
g_ax_handle = handle;
ensure_param(handle, "AdvertiseRoutes", "");
load_config_cache(handle);
write_config_file();
start_child();
const char *params[] = {
"CustomServer", "AuthKey",
#ifdef HAS_PROXY_PORTS
"HttpProxyPort", "Socks5Port",
#endif
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
};
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
if (!ax_parameter_register_callback(handle, params[i],
parameter_changed, handle, &error)) {
syslog(LOG_WARNING, "register callback %s: %s",
params[i], error ? error->message : "unknown");
if (error) { g_error_free(error); error = NULL; }
}
}
http_server_start(handle);
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
g_unix_signal_add(SIGTERM, signal_handler, loop);
g_unix_signal_add(SIGINT, signal_handler, loop);
g_timeout_add_seconds(60, watchdog_cb, NULL);
g_timeout_add_seconds(5, authkey_sentinel_cb, NULL);
syslog(LOG_INFO, "running — watching for parameter changes");
g_main_loop_run(loop);
g_main_loop_unref(loop);
ax_parameter_free(handle);
return 0;
}
+39 -57
View File
@@ -3,22 +3,22 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tailscale VPN for Axis Devices</title>
<meta name="description" content="Install Tailscale VPN directly on your Axis device. Secure remote access with WireGuard, no extra hardware needed.">
<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 Devices">
<meta property="og:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
<meta property="og: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 Devices">
<meta name="twitter:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
<meta name="twitter: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>
@@ -117,11 +117,6 @@
margin-bottom: 1.25rem;
}
.hero h1 span { color: var(--accent); }
.hero-word {
display: inline-block;
color: var(--accent);
transition: opacity 0.25s, transform 0.25s;
}
.hero p {
font-size: 1.2rem;
color: var(--muted);
@@ -228,7 +223,7 @@
width: fit-content;
}
.tag-recommended { background: rgba(52, 211, 153, 0.15); color: var(--green); }
.tag-acap3 { background: rgba(168, 85, 247, 0.15); color: #a855f7; }
.tag-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;
@@ -349,8 +344,8 @@
<!-- Hero -->
<section class="hero">
<div class="badge">Open Source &middot; ACAP Package</div>
<h1>Tailscale VPN for<br>Axis <span class="hero-word" id="heroWord">Cameras</span></h1>
<p>Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.</p>
<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>
@@ -368,7 +363,7 @@
<div style="background:rgba(79,143,247,0.07);border:1px solid rgba(79,143,247,0.18);border-radius:10px;padding:12px 18px;font-size:12.5px;color:var(--muted);line-height:1.6;">
<strong style="color:var(--text);">Disclaimer:</strong>
This is an independent, community-developed ACAP package and is <strong style="color:var(--text);">not an official Axis Communications product</strong>.
It is not affiliated with, endorsed by, or supported by Axis Communications AB.
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>
@@ -383,12 +378,12 @@
<div class="feature-card">
<div class="icon">⚡</div>
<h3>No Root Required</h3>
<p>Runs in user-space networking mode on Axis OS 12+. No need to enable root access on your device.</p>
<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 device's web interface. Start the app and authenticate - done.</p>
<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>
@@ -398,19 +393,19 @@
<div class="feature-card">
<div class="icon">🌐</div>
<h3>Headscale Compatible</h3>
<p>Supports self-hosted Headscale servers with configurable server URL and auth key, built into every variant.</p>
<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>Outbound Proxy</h3>
<p>Allows the device to route its own outbound traffic through Tailscale via a local HTTP/HTTPS and SOCKS5 proxy.</p>
<div class="icon">🏗️</div>
<h3>ARM &amp; 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 device and Axis OS version.</p>
<p class="subtitle">Pick the right variant for your camera and Axis OS version.</p>
<div class="download-grid">
<!-- Standard -->
<div class="download-card">
@@ -428,6 +423,22 @@
</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 &middot; Legacy</div>
@@ -444,18 +455,6 @@
</a>
</div>
</div>
<!-- ACAP3 -->
<div class="download-card">
<div class="tag tag-acap3">ACAP3 &middot; Legacy</div>
<h3>ACAP3 (Older Axis OS)</h3>
<p>For devices running Axis OS versions that do not support ACAP4. ARM only.</p>
<div class="arch-buttons">
<a class="arch-btn" data-asset="acap3" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
ARM
</a>
</div>
</div>
</div>
</section>
@@ -467,14 +466,14 @@
<div class="step-num">1</div>
<div class="step-text">
<strong>Download the EAP</strong>
<span>Grab the right .eap file for your device architecture from the releases page above.</span>
<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 device</strong>
<span>Log into your Axis device web interface and go to <strong>Apps &rarr; Add App</strong>. Upload the .eap file.</span>
<strong>Upload to your camera</strong>
<span>Log into your Axis camera web interface and go to <strong>Apps &rarr; Add App</strong>. Upload the .eap file.</span>
</div>
</div>
<div class="step">
@@ -493,7 +492,6 @@
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases" target="_blank" rel="noopener">Releases</a>
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener">Sponsor</a>
<a href="https://buymeacoffee.com/mo3he" target="_blank" rel="noopener">Buy Me a Coffee</a>
<a href="https://tailscale.com/" target="_blank" rel="noopener">Tailscale</a>
<a href="https://www.axis.com/" target="_blank" rel="noopener">Axis</a>
</div>
@@ -540,22 +538,6 @@
}
});
// Rotate hero word
var heroWord = document.getElementById('heroWord');
var devices = ['Cameras', 'Door Stations', 'Intercoms', 'Speakers', 'Radars', 'Encoders'];
var wordIdx = 0;
function cycleWord() {
wordIdx = (wordIdx + 1) % devices.length;
heroWord.style.opacity = '0';
heroWord.style.transform = 'translateY(8px)';
setTimeout(function() {
heroWord.textContent = devices[wordIdx];
heroWord.style.opacity = '1';
heroWord.style.transform = 'translateY(0)';
}, 250);
}
setInterval(cycleWord, 3000);
// Rewrite download links to point directly to latest release assets
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
.then(function(r) { return r.json(); })
@@ -571,9 +553,9 @@
var archMatch = name.indexOf(arch) !== -1;
var typeMatch = false;
if (type === 'Tailscale_VPN') {
typeMatch = name.indexOf('root') === -1 && name.indexOf('acap3') === -1;
} else if (type === 'acap3') {
typeMatch = name.indexOf('acap3') !== -1;
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;
}