mirror of
https://github.com/Mo3he/Axis_Cam_Tailscale.git
synced 2026-10-01 12:05:37 +00:00
Compare commits
66
Commits
v1.96.4-r3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cbe14b8cf | ||
|
|
230a9f4861 | ||
|
|
1997941965 | ||
|
|
8b70caaf70 | ||
|
|
08f791ed24 | ||
|
|
7d8d3a04cb | ||
|
|
ccd670591e | ||
|
|
7dffcd1f58 | ||
|
|
e23272fedd | ||
|
|
559fe42294 | ||
|
|
3413d3a992 | ||
|
|
f526196d99 | ||
|
|
14518ffe16 | ||
|
|
d615191fc2 | ||
|
|
d2f2cd13a4 | ||
|
|
96f5a7c909 | ||
|
|
86309e1291 | ||
|
|
ed0f6d23fe | ||
|
|
474d783f48 | ||
|
|
9c1bec5043 | ||
|
|
15ac2c3395 | ||
|
|
437f7f9e40 | ||
|
|
53240ed2ec | ||
|
|
492392d175 | ||
|
|
e17549fef0 | ||
|
|
3a71aba342 | ||
|
|
dce8beaa1b | ||
|
|
b70d664f91 | ||
|
|
9ea88ccd0d | ||
|
|
f79f631d95 | ||
|
|
835ccff82f | ||
|
|
92262bd16c | ||
|
|
948c201ea3 | ||
|
|
3730609abe | ||
|
|
b06dbc0059 | ||
|
|
7d32827567 | ||
|
|
970055051d | ||
|
|
216ae1ebf9 | ||
|
|
e2daee6519 | ||
|
|
2a8dccfc05 | ||
|
|
02dda93272 | ||
|
|
0331f8d8c5 | ||
|
|
f3d649aa92 | ||
|
|
bddcdf7de8 | ||
|
|
47c3894002 | ||
|
|
3ed71ccae3 | ||
|
|
c0ef4852ae | ||
|
|
741062bcef | ||
|
|
c4ac8ab601 | ||
|
|
fd5cec50bb | ||
|
|
9a35f4e584 | ||
|
|
896fe380ff | ||
|
|
b398b5498c | ||
|
|
08a2140d68 | ||
|
|
1a5d2c1a24 | ||
|
|
4066273b3f | ||
|
|
924f04c44a | ||
|
|
674f1c4853 | ||
|
|
93855e5762 | ||
|
|
889db273ec | ||
|
|
52572fc61c | ||
|
|
76138394e1 | ||
|
|
db24028d61 | ||
|
|
f5a30122a3 | ||
|
|
768a859c1f | ||
|
|
f395d91de8 |
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
...
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
+149
-176
@@ -1,195 +1,168 @@
|
|||||||
name: Auto Build & Release Tailscale ACAP
|
---
|
||||||
|
# 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
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 0 * * *" # Every Monday at 03:00 UTC
|
- cron: "0 3 * * *"
|
||||||
workflow_dispatch:
|
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:
|
jobs:
|
||||||
build-and-release:
|
check:
|
||||||
runs-on: ubuntu-latest
|
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:
|
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
|
||||||
|
|
||||||
# 1. Checkout repo
|
build:
|
||||||
- uses: actions/checkout@v3
|
needs: check
|
||||||
with:
|
if: needs.check.outputs.build == 'true'
|
||||||
persist-credentials: true
|
runs-on: ubuntu-latest
|
||||||
fetch-depth: 0
|
env:
|
||||||
|
VERSION: ${{ needs.check.outputs.version }}
|
||||||
|
UPSTREAM_VERSION: ${{ needs.check.outputs.upstream }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
# 2. Get latest Tailscale version
|
- name: Apply version and upstream pins
|
||||||
- name: Get latest Tailscale version
|
run: ./ci/apply-version.sh "$VERSION" "$UPSTREAM_VERSION"
|
||||||
id: tailscale_version
|
|
||||||
run: |
|
|
||||||
# 1. Start from GitHub latest
|
|
||||||
GH_TAG=$(curl -s https://api.github.com/repos/tailscale/tailscale/releases/latest | jq -r .tag_name)
|
|
||||||
GH_VERSION=${GH_TAG#v}
|
|
||||||
|
|
||||||
echo "GitHub latest: $GH_VERSION"
|
# Repos without a tests/run.sh simply skip this.
|
||||||
|
- name: Run unit tests
|
||||||
# 2. See if static ARM build exists for that version
|
run: |
|
||||||
if curl -sfI "https://pkgs.tailscale.com/stable/tailscale_${GH_VERSION}_arm.tgz" > /dev/null; then
|
set -euo pipefail
|
||||||
VERSION="$GH_VERSION"
|
if [ -x tests/run.sh ] || [ -f tests/run.sh ]; then
|
||||||
echo "Using GitHub latest (has ARM package): $VERSION"
|
sudo apt-get update
|
||||||
else
|
sudo apt-get install -y --no-install-recommends pkg-config libglib2.0-dev
|
||||||
echo "No ARM package for $GH_VERSION, falling back to latest version on pkgs.tailscale.com"
|
sh tests/run.sh
|
||||||
# 3. Derive latest version that actually has an ARM tarball
|
|
||||||
VERSION=$(
|
|
||||||
curl -s https://pkgs.tailscale.com/stable/ \
|
|
||||||
| grep -o 'tailscale_[0-9.]*_arm\.tgz' \
|
|
||||||
| sed -E 's/^tailscale_([0-9.]+)_arm\.tgz$/\1/' \
|
|
||||||
| sort -V | tail -n1
|
|
||||||
)
|
|
||||||
echo "Fallback version: $VERSION"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Get current repo version
|
|
||||||
id: current
|
|
||||||
run: |
|
|
||||||
CURRENT=$(find . -path "*/app/manifest.json" -exec jq -r '.acapPackageConf.setup.version' {} \; | sort -u | head -n1)
|
|
||||||
echo "CURRENT_VERSION=$CURRENT" >> $GITHUB_ENV
|
|
||||||
echo "Current repo version: $CURRENT"
|
|
||||||
|
|
||||||
- name: Compare versions
|
|
||||||
id: compare
|
|
||||||
run: |
|
|
||||||
echo "Repo version: $CURRENT_VERSION"
|
|
||||||
echo "Latest Tailscale version: $RELEASE_VERSION"
|
|
||||||
echo "Trigger: ${{ github.event_name }}"
|
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
echo "build_needed=true" >> $GITHUB_ENV
|
|
||||||
echo "Manual trigger — building regardless of version."
|
|
||||||
elif [ "$CURRENT_VERSION" = "$RELEASE_VERSION" ]; then
|
|
||||||
echo "build_needed=false" >> $GITHUB_ENV
|
|
||||||
echo "Already up to date. Skipping build."
|
|
||||||
else
|
|
||||||
echo "build_needed=true" >> $GITHUB_ENV
|
|
||||||
echo "New version detected. Will build."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Download Tailscale binaries
|
|
||||||
- name: Download Tailscale binaries
|
|
||||||
if: env.build_needed == 'true'
|
|
||||||
run: |
|
|
||||||
mkdir -p tailscale_bins
|
|
||||||
curl -L "https://pkgs.tailscale.com/stable/tailscale_${RELEASE_VERSION}_arm.tgz" -o tailscale_arm.tgz
|
|
||||||
tar -xzf tailscale_arm.tgz -C tailscale_bins --strip-components=1
|
|
||||||
mv tailscale_bins/tailscale tailscale_bins/tailscale_arm
|
|
||||||
mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm
|
|
||||||
|
|
||||||
curl -L "https://pkgs.tailscale.com/stable/tailscale_${RELEASE_VERSION}_arm64.tgz" -o tailscale_arm64.tgz
|
|
||||||
tar -xzf tailscale_arm64.tgz -C tailscale_bins --strip-components=1
|
|
||||||
mv tailscale_bins/tailscale tailscale_bins/tailscale_arm64
|
|
||||||
mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm64
|
|
||||||
|
|
||||||
# 4. Strip binaries to reduce package size
|
|
||||||
- name: Strip binaries
|
|
||||||
if: env.build_needed == 'true'
|
|
||||||
run: |
|
|
||||||
# Install cross-architecture strip tools
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y binutils-aarch64-linux-gnu binutils-arm-linux-gnueabihf
|
|
||||||
|
|
||||||
# Strip debug info and symbol tables (zero runtime/memory cost)
|
|
||||||
aarch64-linux-gnu-strip -s tailscale_bins/tailscale_arm64
|
|
||||||
aarch64-linux-gnu-strip -s tailscale_bins/tailscaled_arm64
|
|
||||||
arm-linux-gnueabihf-strip -s tailscale_bins/tailscale_arm
|
|
||||||
arm-linux-gnueabihf-strip -s tailscale_bins/tailscaled_arm
|
|
||||||
|
|
||||||
ls -lh tailscale_bins/
|
|
||||||
|
|
||||||
# 5. Build each folder, update manifest, and copy .eap files
|
|
||||||
- name: Build all folders
|
|
||||||
if: env.build_needed == 'true'
|
|
||||||
run: |
|
|
||||||
mkdir -p build
|
|
||||||
rm -rf releases
|
|
||||||
mkdir -p releases
|
|
||||||
|
|
||||||
for folder in */ ; do
|
|
||||||
[[ ! -d "$folder/app" ]] && continue
|
|
||||||
FOLDER_NAME="${folder%/}" # remove trailing slash
|
|
||||||
echo "Processing folder $FOLDER_NAME"
|
|
||||||
|
|
||||||
# Detect architecture
|
|
||||||
if [[ "$FOLDER_NAME" == arm* ]]; then
|
|
||||||
cp tailscale_bins/tailscale_arm "$folder/app/lib/tailscale"
|
|
||||||
cp tailscale_bins/tailscaled_arm "$folder/app/lib/tailscaled"
|
|
||||||
else
|
else
|
||||||
cp tailscale_bins/tailscale_arm64 "$folder/app/lib/tailscale"
|
echo "no tests/run.sh, skipping"
|
||||||
cp tailscale_bins/tailscaled_arm64 "$folder/app/lib/tailscaled"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Detect variant suffix for .eap naming
|
- name: Build packages
|
||||||
if [[ "$FOLDER_NAME" == *_ROOT ]]; then
|
run: ./ci/build-packages.sh
|
||||||
VARIANT="_root"
|
|
||||||
elif [[ "$FOLDER_NAME" == *_acap3 ]]; then
|
- name: Verify packages
|
||||||
VARIANT="_acap3"
|
run: |
|
||||||
else
|
set -euo pipefail
|
||||||
VARIANT=""
|
shopt -s nullglob
|
||||||
|
packages=(releases/*.eap)
|
||||||
|
if [ ${#packages[@]} -eq 0 ]; then
|
||||||
|
echo "no .eap produced" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
for package in "${packages[@]}"; do
|
||||||
# Update version — manifest.json for ACAP 4, package.conf for ACAP 3
|
echo "== $package"
|
||||||
if [[ -f "$folder/app/manifest.json" ]]; then
|
tar tzf "$package" >/dev/null
|
||||||
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
|
||||||
done
|
|
||||||
|
|
||||||
# Clean up
|
- uses: actions/upload-artifact@v7
|
||||||
rm -rf build tailscale_bins *.tgz
|
with:
|
||||||
|
name: packages
|
||||||
|
path: releases/*.eap
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
# 6. Commit updated manifests and .eap files directly to main
|
# Unstripped binaries for symbolising a crash from a shipped (stripped)
|
||||||
- name: Commit updates to main
|
# package. Not a release asset: they are only useful while debugging.
|
||||||
if: env.build_needed == 'true'
|
- uses: actions/upload-artifact@v7
|
||||||
run: |
|
with:
|
||||||
git config user.name "github-actions[bot]"
|
name: debug-symbols
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
path: debug/
|
||||||
# Only commit manifests and ACAP 3 package.conf; do not track release artifacts
|
if-no-files-found: ignore
|
||||||
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
|
# Only after a successful build, so a failed upstream jump leaves main clean.
|
||||||
- name: Create GitHub Release
|
- name: Commit version bump
|
||||||
if: env.build_needed == 'true'
|
if: needs.check.outputs.release == 'true' && github.event_name != 'pull_request'
|
||||||
uses: softprops/action-gh-release@v1
|
run: |
|
||||||
with:
|
set -euo pipefail
|
||||||
tag_name: v${{ env.RELEASE_VERSION }}
|
git config user.name "github-actions[bot]"
|
||||||
name: "Tailscale VPN ${{ env.RELEASE_VERSION }}"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
files: releases/*
|
# Packages are already uploaded as an artifact; removing them here
|
||||||
env:
|
# keeps build output out of the commit regardless of .gitignore.
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: packages
|
||||||
|
path: releases
|
||||||
|
|
||||||
|
# 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
|
||||||
|
else
|
||||||
|
gh release create "$tag" releases/*.eap \
|
||||||
|
--draft \
|
||||||
|
--title "Tailscale VPN $VERSION" \
|
||||||
|
--notes-file /tmp/notes.md
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
# Advanced setup: default setup cannot select javascript-typescript here
|
||||||
|
# because the UI scripts live inside index.html rather than a .js file.
|
||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: "24 4 * * 1"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze ${{ matrix.language }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [c-cpp, javascript-typescript]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: none
|
||||||
|
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
category: "/language:${{ matrix.language }}"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
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 }}
|
||||||
+5
-1
@@ -1,9 +1,13 @@
|
|||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
**/build
|
**/build
|
||||||
|
|
||||||
# Do not track release artifacts
|
# Do not track release artifacts (local .eap build outputs stay untracked anywhere in the tree)
|
||||||
releases/
|
releases/
|
||||||
build/
|
build/
|
||||||
|
*.eap
|
||||||
|
|
||||||
|
# Unstripped binaries kept for crash symbolisation, uploaded as a CI artifact
|
||||||
|
debug/
|
||||||
|
|
||||||
# Do not track downloaded Tailscale tarballs and temp bins
|
# Do not track downloaded Tailscale tarballs and temp bins
|
||||||
tailscale_bins/
|
tailscale_bins/
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
rules:
|
||||||
|
line-length: disable
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
# 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
@@ -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 have a question](#i-have-a-question)
|
||||||
- [I want to contribute](#i-want-to-contribute)
|
- [I want to contribute](#i-want-to-contribute)
|
||||||
- [Reporting bugs](#reporting-bugs)
|
- [Reporting bugs](#reporting-bugs)
|
||||||
- [Before submitting a bug report](#before-submitting-a-bug-report)
|
- [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)
|
- [How do I submit a good bug report?](#how-do-i-submit-a-good-bug-report)
|
||||||
- [Suggesting enhancements](#suggesting-enhancements)
|
- [Suggesting enhancements](#suggesting-enhancements)
|
||||||
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
|
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
|
||||||
- [How do I submit a good enhancement suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
|
- [How do I submit a good enhancement suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
|
||||||
- [Your first code contribution](#your-first-code-contribution)
|
- [Your first code contribution](#your-first-code-contribution)
|
||||||
|
|
||||||
## I have a question
|
## 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].
|
- 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.
|
- 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:
|
- Collect information about the bug:
|
||||||
- Axis device model
|
- Axis device model
|
||||||
- Axis device firmware version
|
- Axis device firmware version
|
||||||
- Stack trace
|
- Stack trace
|
||||||
- OS and version (Windows, Linux, macOS, x86, ARM)
|
- OS and version (Windows, Linux, macOS, x86, ARM)
|
||||||
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant
|
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant
|
||||||
- Possibly your input and the output
|
- Possibly your input and the output
|
||||||
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
|
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
|
||||||
|
|
||||||
#### How do I submit a good bug report?
|
#### 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.
|
And finally when you are satisfied with your changes, open a new PR.
|
||||||
|
|
||||||
<!-- markdownlint-disable MD034 -->
|
<!-- markdownlint-disable MD034 -->
|
||||||
[issues]: https://github.com/AxisCommunications/tailscale-acap/issues
|
[issues]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues
|
||||||
[issues_new]: https://github.com/AxisCommunications/tailscale-acap/issues/new
|
[issues_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues/new
|
||||||
[issues_bugs]: https://github.com/AxisCommunications/tailscale-acap/issues?q=label%3Abug
|
[issues_bugs]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues?q=label%3Abug
|
||||||
[discussions]: https://github.com/AxisCommunications/tailscale-acap/discussions
|
[discussions]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions
|
||||||
[discussions_new]: https://github.com/AxisCommunications/tailscale-acap/discussions/new
|
[discussions_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions/new
|
||||||
<!-- markdownlint-enable MD034 -->
|
<!-- markdownlint-enable MD034 -->
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
BSD 3-Clause License
|
BSD 3-Clause License
|
||||||
|
|
||||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
Copyright (c) 2022, Weston Blieden
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
modification, are permitted provided that the following conditions are met:
|
modification, are permitted provided that the following conditions are met:
|
||||||
@@ -26,4 +25,4 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|||||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
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
|
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.
|
||||||
|
|||||||
@@ -1,71 +1,114 @@
|
|||||||
# Tailscale ACAP for Axis Cameras
|
# Tailscale ACAP for Axis Cameras
|
||||||
|
|
||||||
|
[](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
|
||||||
|
[](https://github.com/Mo3he/Axis_Cam_Tailscale/actions/workflows/build.yml)
|
||||||
|
[](https://github.com/Mo3he/Axis_Cam_Tailscale/actions/workflows/super-linter.yml)
|
||||||
|
[](https://github.com/sponsors/Mo3he)
|
||||||
|
[](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/)**
|
**[Visit the Homepage](https://mo3he.github.io/Axis_Cam_Tailscale/)**
|
||||||
|
|
||||||
This repository provides an **ACAP package** that installs the [Tailscale VPN client](https://tailscale.com/) on Axis cameras.
|
> **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.
|
||||||
|
|
||||||
- Secure remote access to cameras
|
> **Tailscale Notice:** Tailscale is a product of Tailscale Inc. This package
|
||||||
- Easy to install via EAP package
|
> independently redistributes the Tailscale binaries under the
|
||||||
- Works on **Axis OS 11.11+** (non-root version)
|
> [BSD 3-Clause License](LICENSE) and is not affiliated with, endorsed by, or
|
||||||
- Works on **legacy Axis OS 9.x / 10.x** via the ACAP 3 variant
|
> supported by Tailscale Inc. For the official Tailscale client, visit
|
||||||
- Based on **WireGuard VPN** technology
|
> [tailscale.com](https://tailscale.com).
|
||||||
|
|
||||||
[](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
|
|
||||||
[](LICENSE)
|
|
||||||

|
|
||||||
[](https://github.com/sponsors/Mo3he)
|
|
||||||
[](https://www.buymeacoffee.com/mo3he)
|
|
||||||
|
|
||||||
> **Disclaimer:** This is an independent, community-developed ACAP package and is not an official Axis Communications product. It is not affiliated with, endorsed by, or supported by Axis Communications AB. Use it at your own risk. For official Axis software, visit axis.com
|
|
||||||
|
|
||||||
> **Tailscale Notice:** Tailscale is a product of Tailscale Inc. This package independently redistributes the Tailscale binaries under the [BSD 3-Clause License](LICENSE) and is not affiliated with, endorsed by, or supported by Tailscale Inc. For the official Tailscale client, visit [tailscale.com](https://tailscale.com).
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Installation](#installation)
|
- [Overview](#overview)
|
||||||
- [Usage](#usage)
|
- [Compatibility](#compatibility)
|
||||||
- [Settings](#settings)
|
- [Installation](#installation)
|
||||||
- [Proxy Support](#proxy-support)
|
- [Configuration](#configuration)
|
||||||
- [Updating Tailscale](#updating-tailscale)
|
- [Ports & security](#ports--security)
|
||||||
- [Purpose](#purpose)
|
- [Accessing Tailnet services from the camera](#accessing-tailnet-services-from-the-camera)
|
||||||
- [Useful Links](#useful-links)
|
- [Updating Tailscale](#updating-tailscale)
|
||||||
- [Compatibility](#compatibility)
|
- [Build from source](#build-from-source)
|
||||||
- [Star History](#star-history)
|
- [Roadmap](#roadmap)
|
||||||
- [Support](#support)
|
- [Links](#links)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
---
|
## 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
|
## Installation
|
||||||
|
|
||||||
Get the **prebuilt `.eap` file** from the [Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
|
> **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.
|
||||||
|
|
||||||
1. Log into your Axis camera.
|
Get the **prebuilt `.eap` file** from the
|
||||||
2. Go to **Apps → Add App**.
|
[Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
|
||||||
3. Upload the `.eap` file.
|
|
||||||
|
1. Log into your Axis camera.
|
||||||
|
2. Go to **Apps -> Add App**.
|
||||||
|
3. Upload the `.eap` file.
|
||||||
|
|
||||||
Once installed:
|
Once installed:
|
||||||
- Start the app.
|
|
||||||
- Click **Open** to view logs and get your Tailscale authentication URL.
|
- Start the app.
|
||||||
- On uninstall, all changes/files are removed.
|
- 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.
|
> You'll need a [Tailscale account](https://tailscale.com/) to authenticate.
|
||||||
|
|
||||||
---
|
## Configuration
|
||||||
|
|
||||||
## Usage
|
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.
|
||||||
|
|
||||||
- Runs a C-based parameter bridge (compiled via ACAP SDK 1.15.1) that reads settings from the ACAP parameter store and launches Tailscale.
|
All parameters are configurable via the web UI (**Open -> Settings** card) and
|
||||||
- View logs and connection status via the **Open** button in the app.
|
take effect immediately:
|
||||||
- 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 |
|
| Parameter | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -73,122 +116,140 @@ All parameters are configurable via the web UI (**Open → Settings** card) and
|
|||||||
| Auth Key | *(empty)* | Pre-authentication key (`tskey-auth-...`). Cleared automatically after first successful connection. Leave blank to authenticate via browser. |
|
| 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. |
|
| HTTP Proxy Port | `8080` | Port for the outbound HTTP/HTTPS proxy. |
|
||||||
| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 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**.
|
||||||
|
|
||||||
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.
|
| Proxy | Default address | Routes |
|
||||||
|
|
||||||
### HTTP CONNECT Proxy — `http://127.0.0.1:8080` (default)
|
|
||||||
|
|
||||||
Routes HTTP and HTTPS traffic. Set this wherever an HTTP/HTTPS proxy field is available on the camera:
|
|
||||||
|
|
||||||
| Location | Field | Value |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| System → Network → Global proxies | HTTP proxy | `http://127.0.0.1:<port>` |
|
| HTTP CONNECT | `http://127.0.0.1:8080` | HTTP and HTTPS traffic |
|
||||||
| System → Network → Global proxies | HTTPS proxy | `http://127.0.0.1:<port>` |
|
| SOCKS5 | `127.0.0.1:1080` | Any SOCKS5-aware app or service |
|
||||||
| System → MQTT → Broker | HTTP proxy | `http://127.0.0.1:<port>` |
|
|
||||||
| System → MQTT → Broker | HTTPS proxy | `http://127.0.0.1:<port>` |
|
|
||||||
|
|
||||||
### SOCKS5 Proxy — `127.0.0.1:1080` (default)
|
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>`.
|
||||||
|
|
||||||
For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:<port>`.
|
> **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.
|
||||||
|
|
||||||
> The active proxy addresses are always shown in the **Proxy Configuration** card of the web UI.
|
## Accessing Tailnet services from the camera
|
||||||
|
|
||||||
> 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.
|
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:
|
||||||
|
|
||||||
---
|
| 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
|
## Updating Tailscale
|
||||||
|
|
||||||
- New `.eap` files are auto-built and released **weekly** (if a new Tailscale version is available).
|
- New `.eap` files are auto-built and released **weekly** (if a new Tailscale
|
||||||
- To update, simply install the new `.eap` over the existing one.
|
version is available).
|
||||||
|
- To update, simply install the new `.eap` over the existing one.
|
||||||
|
|
||||||
### Manual update (advanced)
|
### Manual update (advanced)
|
||||||
|
|
||||||
Replace the binaries in the `lib/` folder:
|
Replace the binaries in `common/app/lib/` (shared by `aarch64`, `armv7hf`, and
|
||||||
|
their ROOT variants) or `arm_acap3/app/lib/` (legacy variant, kept separate):
|
||||||
|
|
||||||
- `tailscale`
|
- `tailscale`
|
||||||
- `tailscaled`
|
- `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 locally
|
## Build from source
|
||||||
|
|
||||||
From the main directory of the version you want (`arm` / `aarch64`):
|
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`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build --tag <package_name> .
|
docker build -f aarch64/Dockerfile --tag <package_name> .
|
||||||
docker cp $(docker create <package_name>):/opt/app ./build
|
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`.)
|
||||||
|
|
||||||
## Good News
|
## Roadmap
|
||||||
|
|
||||||
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 11.11+**.
|
### AXIS OS 13 Preparation
|
||||||
|
|
||||||
- Runs in **user space networking mode**.
|
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.
|
||||||
|
|
||||||
For **full kernel networking**, use the **ROOT** version. Note: ROOT mode requires Axis OS 11.11–11.x — Axis OS 12 and later removed root access for third-party applications.
|
- [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.
|
||||||
|
|
||||||
### Legacy camera support (Axis OS 9.x / 10.x)
|
### General Improvements
|
||||||
|
|
||||||
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.
|
- [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).
|
||||||
|
|
||||||
---
|
## Links
|
||||||
|
|
||||||
## Purpose
|
- [Tailscale](https://tailscale.com/)
|
||||||
|
- [Tailscale GitHub](https://github.com/tailscale/tailscale)
|
||||||
|
- [WireGuard](https://www.wireguard.com/)
|
||||||
|
- [Axis Communications](https://www.axis.com/)
|
||||||
|
|
||||||
Adding a VPN client directly to the camera enables:
|
## License
|
||||||
- Secure remote access without additional hardware or complex network configuration.
|
|
||||||
- Easy setup through Tailscale’s lightweight WireGuard-based tunnel.
|
|
||||||
|
|
||||||
Learn more: [How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/)
|
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).
|
||||||
## 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 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
|
|
||||||
| `armv7hf_root` | ARMv7 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
|
|
||||||
| `armv7hf_acap3` | ARMv7 | **9.x – 10.x** | Legacy cameras, ACAP SDK 3 |
|
|
||||||
|
|
||||||
> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 12+ → use the standard variant (`aarch64` or `armv7hf`). Axis OS 11.11–11.x → standard variant, or ROOT if you need kernel networking. Axis OS 9.x/10.x on ARMv7 → use `armv7hf_acap3`.
|
|
||||||
|
|
||||||
You can verify your device details using the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --anyauth "*" -u <username>:<password> <device_ip>/axis-cgi/basicdeviceinfo.cgi --data '{"apiVersion":"1.0","context":"Client defined request ID","method":"getAllProperties"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
> Replace `<device_ip>`, `<username>`, and `<password>` with your device credentials.
|
|
||||||
> Enclose your password in quotes `' '` if it contains special characters.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you like this project and want to support my work:
|
|
||||||
[Sponsor Me](https://github.com/sponsors/Mo3he)
|
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
BSD 3-Clause License
|
# Third-Party Notices
|
||||||
|
|
||||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
This ACAP package redistributes the Tailscale client. The ACAP's own wrapper
|
||||||
All rights reserved.
|
code is licensed separately (see `LICENSE`, BSD 3-Clause).
|
||||||
|
|
||||||
|
## 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
|
Redistribution and use in source and binary forms, with or without
|
||||||
modification, are permitted provided that the following conditions are met:
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
@@ -13,9 +25,9 @@ modification, are permitted provided that the following conditions are met:
|
|||||||
this list of conditions and the following disclaimer in the documentation
|
this list of conditions and the following disclaimer in the documentation
|
||||||
and/or other materials provided with the distribution.
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
3. Neither the name of the copyright holder nor the names of its
|
3. Neither the name of the copyright holder nor the names of its contributors
|
||||||
contributors may be used to endorse or promote products derived from
|
may be used to endorse or promote products derived from this software
|
||||||
this software without specific prior written permission.
|
without specific prior written permission.
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
@@ -26,4 +38,5 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|||||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
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
|
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.
|
||||||
|
```
|
||||||
+5
-4
@@ -1,13 +1,14 @@
|
|||||||
ARG ARCH=aarch64
|
ARG ARCH=aarch64
|
||||||
ARG VERSION=1.15.1
|
ARG VERSION=12.10.0
|
||||||
ARG UBUNTU_VERSION=22.04
|
ARG UBUNTU_VERSION=24.04
|
||||||
ARG REPO=axisecp
|
ARG REPO=axisecp
|
||||||
ARG SDK=acap-native-sdk
|
ARG SDK=acap-native-sdk
|
||||||
|
|
||||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||||
|
|
||||||
# Building the ACAP application
|
COPY common/app /opt/app/
|
||||||
COPY ./app /opt/app/
|
COPY aarch64/app/manifest.json /opt/app/manifest.json
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
|
ENV EXTRA_CFLAGS=-DHAS_PROXY_PORTS
|
||||||
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
||||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
To build, from main directory
|
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,76 +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).
|
|
||||||
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
|
|
||||||
@@ -1,852 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Tailscale VPN</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #0f1117;
|
|
||||||
--surface: #181b23;
|
|
||||||
--surface2: #1e2230;
|
|
||||||
--border: #262a35;
|
|
||||||
--text: #e4e6ed;
|
|
||||||
--muted: #8b8fa3;
|
|
||||||
--accent: #2e2d2d;
|
|
||||||
--green: #22c55e;
|
|
||||||
--yellow: #f59e0b;
|
|
||||||
--red: #ef4444;
|
|
||||||
--radius: 10px;
|
|
||||||
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] {
|
|
||||||
--bg: #f5f6f8;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface2: #f0f1f4;
|
|
||||||
--border: #e0e3e8;
|
|
||||||
--text: #1a1a2e;
|
|
||||||
--muted: #6b7084;
|
|
||||||
--accent: #2e2d2d;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.theme-btn {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
|
|
||||||
.theme-btn svg { width: 16px; height: 16px; }
|
|
||||||
|
|
||||||
/* Cards */
|
|
||||||
.card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status */
|
|
||||||
.status-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
|
|
||||||
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
|
|
||||||
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
|
|
||||||
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
|
|
||||||
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.status-banner.connected .status-text { color: var(--green); }
|
|
||||||
.status-banner.connecting .status-text { color: var(--yellow); }
|
|
||||||
.status-banner.disconnected .status-text { color: var(--red); }
|
|
||||||
|
|
||||||
.status-time {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Auth block */
|
|
||||||
.auth-block {
|
|
||||||
background: rgba(245,158,11,0.08);
|
|
||||||
border: 1px solid rgba(245,158,11,0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.auth-block p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.auth-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 8px 18px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.auth-btn:hover { opacity: 0.9; }
|
|
||||||
.auth-url {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
word-break: break-all;
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info grid */
|
|
||||||
.info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.info-item {
|
|
||||||
background: var(--surface2);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
.info-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.info-value {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--mono);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.info-value.dim { color: var(--muted); font-weight: 400; }
|
|
||||||
|
|
||||||
/* Log viewer */
|
|
||||||
.log-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.log-badge {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
.log-toggle {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.log-toggle:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.log-box {
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 11.5px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.log-box .log-line { display: block; }
|
|
||||||
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
|
|
||||||
.log-line .ts { color: var(--muted); opacity: 0.6; }
|
|
||||||
.log-line .msg-info { color: var(--accent); }
|
|
||||||
.log-line .msg-warn { color: var(--yellow); }
|
|
||||||
.log-line .msg-err { color: var(--red); }
|
|
||||||
.log-line .msg-ok { color: var(--green); }
|
|
||||||
|
|
||||||
/* Settings form */
|
|
||||||
.settings-form { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.settings-row { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
|
|
||||||
.settings-input {
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
padding: 8px 10px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.settings-input:focus { border-color: var(--accent); }
|
|
||||||
.settings-hint { font-size: 11px; color: var(--muted); }
|
|
||||||
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
|
|
||||||
.save-btn {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 18px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.save-btn:hover { opacity: 0.9; }
|
|
||||||
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.save-status { font-size: 12px; color: var(--muted); }
|
|
||||||
.save-status.ok { color: var(--green); }
|
|
||||||
.save-status.err { color: var(--red); }
|
|
||||||
|
|
||||||
/* Refresh indicator */
|
|
||||||
.refresh-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Update banner */
|
|
||||||
.update-banner {
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
background: rgba(46,45,45,0.1);
|
|
||||||
border: 1px solid rgba(46,45,45,0.2);
|
|
||||||
}
|
|
||||||
.update-banner.visible { display: flex; }
|
|
||||||
.update-banner .update-text {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.update-banner .update-text strong { color: var(--accent); }
|
|
||||||
.update-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.update-btn:hover { opacity: 0.9; }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
body { padding: 14px; }
|
|
||||||
.info-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1>Tailscale VPN</h1>
|
|
||||||
</div>
|
|
||||||
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
|
|
||||||
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
|
||||||
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div id="status-banner" class="status-banner connecting">
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span id="status-text" class="status-text">Checking...</span>
|
|
||||||
<span id="status-time" class="status-time"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Update available -->
|
|
||||||
<div id="update-banner" class="update-banner">
|
|
||||||
<div class="update-text">Update available: <strong id="update-version"></strong></div>
|
|
||||||
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
|
||||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auth (hidden by default) -->
|
|
||||||
<div id="auth-block" class="auth-block" style="display:none;">
|
|
||||||
<p>Authenticate this device to connect to your Tailscale network:</p>
|
|
||||||
<a id="auth-link" class="auth-btn" href="#" target="_blank">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
||||||
Open Login Page
|
|
||||||
</a>
|
|
||||||
<span id="auth-url-text" class="auth-url"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connection Info -->
|
|
||||||
<div class="card" id="info-card" style="display:none;">
|
|
||||||
<div class="card-title">Connection Details</div>
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Tailscale IP</div>
|
|
||||||
<div class="info-value" id="ts-ip">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Node Name</div>
|
|
||||||
<div class="info-value" id="ts-node">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Account</div>
|
|
||||||
<div class="info-value" id="ts-tailnet">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Version</div>
|
|
||||||
<div class="info-value" id="ts-version">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:14px;text-align:right;">
|
|
||||||
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Proxy Info (always visible) -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">Proxy Configuration</div>
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">HTTP/HTTPS Proxy</div>
|
|
||||||
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">SOCKS5 Proxy</div>
|
|
||||||
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">Settings</div>
|
|
||||||
<div class="settings-form">
|
|
||||||
<div class="settings-row">
|
|
||||||
<label class="settings-label" for="input-server">Custom Server URL</label>
|
|
||||||
<input class="settings-input" id="input-server" type="text" 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLogs(txt) {
|
|
||||||
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
|
|
||||||
document.getElementById('log-count').textContent = lines.length + ' lines';
|
|
||||||
|
|
||||||
var h = '';
|
|
||||||
for (var i = 0; i < lines.length; i++) {
|
|
||||||
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
|
|
||||||
var cls = classifyLine(lines[i]);
|
|
||||||
if (parts) {
|
|
||||||
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
|
|
||||||
} else {
|
|
||||||
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logBox.innerHTML = h;
|
|
||||||
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(r) {
|
|
||||||
var banner = document.getElementById('status-banner');
|
|
||||||
var statusText = document.getElementById('status-text');
|
|
||||||
var auth = document.getElementById('auth-block');
|
|
||||||
var info = document.getElementById('info-card');
|
|
||||||
|
|
||||||
banner.className = 'status-banner ' + r.state;
|
|
||||||
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
|
|
||||||
statusText.textContent = labels[r.state];
|
|
||||||
|
|
||||||
if (r.state === 'connecting' && r.url) {
|
|
||||||
document.getElementById('auth-link').href = r.url;
|
|
||||||
document.getElementById('auth-url-text').textContent = r.url;
|
|
||||||
auth.style.display = '';
|
|
||||||
} else {
|
|
||||||
auth.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
Binary file not shown.
Binary file not shown.
+63
-36
@@ -1,40 +1,67 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "1.7.0",
|
"schemaVersion": "2.0.0",
|
||||||
"acapPackageConf": {
|
"acapPackageConf": {
|
||||||
"setup": {
|
"setup": {
|
||||||
"appName": "Tailscale_VPN",
|
"appName": "Tailscale_VPN",
|
||||||
"friendlyName": "Tailscale VPN",
|
"friendlyName": "Tailscale VPN",
|
||||||
"vendor": "Mo3he",
|
"vendor": "moshe@mohome.net",
|
||||||
"embeddedSdkVersion": "3.0",
|
"vendorId": "70ee172dd9",
|
||||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||||
"runMode": "respawn",
|
"version": "1.102.4",
|
||||||
"version": "1.96.4",
|
"architecture": "aarch64",
|
||||||
"architecture": "aarch64"
|
"runMode": "respawn",
|
||||||
},
|
"compatibleOsVersions": [
|
||||||
"configuration": {
|
{
|
||||||
"settingPage": "index.html",
|
"max": "13"
|
||||||
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
// Copyright (C) 2024 Mo3he
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACAP parameter bridge for Tailscale VPN (userspace variant).
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
|
|
||||||
* 2. Write them to CONFIG_FILE so the shell script can source them.
|
|
||||||
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
|
|
||||||
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
|
|
||||||
* of the child so the new config is picked up.
|
|
||||||
* Rapid changes within 300 ms are coalesced into a single restart.
|
|
||||||
* 5. Watchdog: if the child exits unexpectedly, restart it.
|
|
||||||
*
|
|
||||||
* Runs as the unprivileged 'sdk' ACAP user — no root required.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <axsdk/axparameter.h>
|
|
||||||
#include <glib-unix.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <syslog.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include <sys/types.h>
|
|
||||||
#include <sys/wait.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <signal.h>
|
|
||||||
|
|
||||||
#define APP_NAME "Tailscale_VPN"
|
|
||||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
|
||||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
|
||||||
|
|
||||||
static AXParameter *g_ax_handle = NULL;
|
|
||||||
static pid_t child_pid = -1;
|
|
||||||
static guint reload_timer_id = 0;
|
|
||||||
|
|
||||||
static char *cfg_custom_server = NULL;
|
|
||||||
static char *cfg_auth_key = NULL;
|
|
||||||
static char *cfg_http_proxy_port = NULL;
|
|
||||||
static char *cfg_socks5_port = NULL;
|
|
||||||
|
|
||||||
static void cache_set(char **field, const char *value) {
|
|
||||||
if (!value) return;
|
|
||||||
free(*field);
|
|
||||||
*field = strdup(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const char *cache_get(char **field, const char *fallback) {
|
|
||||||
return (*field && **field) ? *field : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── child process management ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static void stop_child(void) {
|
|
||||||
if (child_pid <= 0)
|
|
||||||
return;
|
|
||||||
kill(child_pid, SIGTERM);
|
|
||||||
for (int i = 0; i < 30; i++) {
|
|
||||||
int status;
|
|
||||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
|
||||||
child_pid = -1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
usleep(100000);
|
|
||||||
}
|
|
||||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
|
||||||
kill(child_pid, SIGKILL);
|
|
||||||
waitpid(child_pid, NULL, 0);
|
|
||||||
child_pid = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void start_child(void) {
|
|
||||||
stop_child();
|
|
||||||
pid_t pid = fork();
|
|
||||||
if (pid < 0) {
|
|
||||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pid == 0) {
|
|
||||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
|
||||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
|
||||||
_exit(1);
|
|
||||||
}
|
|
||||||
child_pid = pid;
|
|
||||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── watchdog ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
|
||||||
if (child_pid > 0) {
|
|
||||||
int status;
|
|
||||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
|
||||||
if (ret == child_pid) {
|
|
||||||
int exit_code = WEXITSTATUS(status);
|
|
||||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
|
||||||
child_pid = -1;
|
|
||||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
|
||||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
|
||||||
GError *err = NULL;
|
|
||||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
|
||||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
|
||||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
|
||||||
} else {
|
|
||||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
|
||||||
err ? err->message : "unknown");
|
|
||||||
if (err) g_error_free(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
start_child();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return G_SOURCE_CONTINUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── config file ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static void load_config_cache(AXParameter *handle) {
|
|
||||||
GError *error = NULL;
|
|
||||||
gchar *val = NULL;
|
|
||||||
|
|
||||||
#define LOAD(name, field) \
|
|
||||||
val = NULL; error = NULL; \
|
|
||||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
|
||||||
free(field); field = val ? strdup(val) : strdup(""); \
|
|
||||||
g_free(val); val = NULL; \
|
|
||||||
} else { \
|
|
||||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
|
||||||
error ? error->message : "unknown"); \
|
|
||||||
if (error) { g_error_free(error); error = NULL; } \
|
|
||||||
}
|
|
||||||
|
|
||||||
LOAD("CustomServer", cfg_custom_server)
|
|
||||||
LOAD("AuthKey", cfg_auth_key)
|
|
||||||
LOAD("HttpProxyPort", cfg_http_proxy_port)
|
|
||||||
LOAD("Socks5Port", cfg_socks5_port)
|
|
||||||
#undef LOAD
|
|
||||||
}
|
|
||||||
|
|
||||||
static void write_config_file(void) {
|
|
||||||
FILE *f = fopen(CONFIG_FILE, "w");
|
|
||||||
if (!f) {
|
|
||||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
|
||||||
CONFIG_FILE, strerror(errno));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
|
||||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
|
||||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
|
||||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
|
||||||
fclose(f);
|
|
||||||
chmod(CONFIG_FILE, 0600);
|
|
||||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
|
||||||
cache_get(&cfg_http_proxy_port, "8080"),
|
|
||||||
cache_get(&cfg_socks5_port, "1080"),
|
|
||||||
cache_get(&cfg_custom_server, "(default)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
|
||||||
reload_timer_id = 0;
|
|
||||||
if (g_ax_handle)
|
|
||||||
load_config_cache(g_ax_handle);
|
|
||||||
write_config_file();
|
|
||||||
syslog(LOG_INFO, "restarting with new config");
|
|
||||||
stop_child();
|
|
||||||
start_child();
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void parameter_changed(const gchar *name, const gchar *value,
|
|
||||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
|
||||||
const char *dot = strrchr(name, '.');
|
|
||||||
const char *short_name = dot ? dot + 1 : name;
|
|
||||||
|
|
||||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
|
||||||
|
|
||||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
|
||||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
|
||||||
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
|
|
||||||
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
|
|
||||||
|
|
||||||
if (reload_timer_id)
|
|
||||||
g_source_remove(reload_timer_id);
|
|
||||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── signal handler ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static gboolean signal_handler(gpointer loop) {
|
|
||||||
syslog(LOG_INFO, "stopping");
|
|
||||||
stop_child();
|
|
||||||
g_main_loop_quit((GMainLoop *)loop);
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── main ────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
int main(void) {
|
|
||||||
GError *error = NULL;
|
|
||||||
|
|
||||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
|
||||||
syslog(LOG_INFO, "starting");
|
|
||||||
|
|
||||||
/* Ensure localdata dir exists */
|
|
||||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
|
||||||
|
|
||||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
|
||||||
if (!handle) {
|
|
||||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
|
||||||
error ? error->message : "unknown");
|
|
||||||
if (error) g_error_free(error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
g_ax_handle = handle;
|
|
||||||
|
|
||||||
load_config_cache(handle);
|
|
||||||
write_config_file();
|
|
||||||
start_child();
|
|
||||||
|
|
||||||
const char *params[] = {
|
|
||||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
|
|
||||||
};
|
|
||||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
|
||||||
if (!ax_parameter_register_callback(handle, params[i],
|
|
||||||
parameter_changed, handle, &error)) {
|
|
||||||
syslog(LOG_WARNING, "register callback %s: %s",
|
|
||||||
params[i], error ? error->message : "unknown");
|
|
||||||
if (error) { g_error_free(error); error = NULL; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
|
||||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
|
||||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
|
||||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
|
||||||
|
|
||||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
|
||||||
g_main_loop_run(loop);
|
|
||||||
|
|
||||||
g_main_loop_unref(loop);
|
|
||||||
ax_parameter_free(handle);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ ARG SDK=acap-native-sdk
|
|||||||
|
|
||||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||||
|
|
||||||
# Building the ACAP application
|
COPY common/app /opt/app/
|
||||||
COPY ./app /opt/app/
|
COPY aarch64_ROOT/app/manifest.json /opt/app/manifest.json
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
||||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
To build, from main directory
|
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,14 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/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
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,34 +1,56 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "1.7.0",
|
"schemaVersion": "1.7.0",
|
||||||
"acapPackageConf": {
|
"acapPackageConf": {
|
||||||
"setup": {
|
"setup": {
|
||||||
"appName": "Tailscale_VPN",
|
"appName": "Tailscale_VPN",
|
||||||
"friendlyName": "Tailscale VPN",
|
"friendlyName": "Tailscale VPN",
|
||||||
"vendor": "Mo3he",
|
"vendor": "Mo3he",
|
||||||
"embeddedSdkVersion": "3.0",
|
"embeddedSdkVersion": "3.0",
|
||||||
"user": {
|
"user": {
|
||||||
"group": "root",
|
"group": "root",
|
||||||
"username": "root"
|
"username": "root"
|
||||||
},
|
},
|
||||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||||
"runMode": "respawn",
|
"runMode": "respawn",
|
||||||
"version": "1.96.4",
|
"version": "1.102.4",
|
||||||
"architecture": "aarch64"
|
"architecture": "aarch64"
|
||||||
},
|
},
|
||||||
"configuration": {
|
"configuration": {
|
||||||
"settingPage": "index.html",
|
"settingPage": "index.html",
|
||||||
"paramConfig": [
|
"reverseProxy": [
|
||||||
{
|
{
|
||||||
"name": "CustomServer",
|
"apiPath": "api",
|
||||||
"default": "",
|
"target": "http://localhost:2201/",
|
||||||
"type": "string"
|
"access": "admin"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
// Copyright (C) 2024 Mo3he
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
|
|
||||||
* Same structure as regular param_bridge.c but without proxy port params.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <axsdk/axparameter.h>
|
|
||||||
#include <glib-unix.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <syslog.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include <sys/types.h>
|
|
||||||
#include <sys/wait.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <signal.h>
|
|
||||||
|
|
||||||
#define APP_NAME "Tailscale_VPN"
|
|
||||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
|
||||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
|
||||||
|
|
||||||
static AXParameter *g_ax_handle = NULL;
|
|
||||||
static pid_t child_pid = -1;
|
|
||||||
static guint reload_timer_id = 0;
|
|
||||||
|
|
||||||
static char *cfg_custom_server = NULL;
|
|
||||||
static char *cfg_auth_key = NULL;
|
|
||||||
|
|
||||||
static void cache_set(char **field, const char *value) {
|
|
||||||
if (!value) return;
|
|
||||||
free(*field);
|
|
||||||
*field = strdup(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const char *cache_get(char **field, const char *fallback) {
|
|
||||||
return (*field && **field) ? *field : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void stop_child(void) {
|
|
||||||
if (child_pid <= 0)
|
|
||||||
return;
|
|
||||||
kill(child_pid, SIGTERM);
|
|
||||||
for (int i = 0; i < 30; i++) {
|
|
||||||
int status;
|
|
||||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
|
||||||
child_pid = -1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
usleep(100000);
|
|
||||||
}
|
|
||||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
|
||||||
kill(child_pid, SIGKILL);
|
|
||||||
waitpid(child_pid, NULL, 0);
|
|
||||||
child_pid = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void start_child(void) {
|
|
||||||
stop_child();
|
|
||||||
pid_t pid = fork();
|
|
||||||
if (pid < 0) {
|
|
||||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pid == 0) {
|
|
||||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
|
||||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
|
||||||
_exit(1);
|
|
||||||
}
|
|
||||||
child_pid = pid;
|
|
||||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
|
||||||
}
|
|
||||||
|
|
||||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
|
||||||
if (child_pid > 0) {
|
|
||||||
int status;
|
|
||||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
|
||||||
if (ret == child_pid) {
|
|
||||||
int exit_code = WEXITSTATUS(status);
|
|
||||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
|
||||||
child_pid = -1;
|
|
||||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
|
||||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
|
||||||
GError *err = NULL;
|
|
||||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
|
||||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
|
||||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
|
||||||
} else {
|
|
||||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
|
||||||
err ? err->message : "unknown");
|
|
||||||
if (err) g_error_free(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
start_child();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return G_SOURCE_CONTINUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void load_config_cache(AXParameter *handle) {
|
|
||||||
GError *error = NULL;
|
|
||||||
gchar *val = NULL;
|
|
||||||
|
|
||||||
#define LOAD(name, field) \
|
|
||||||
val = NULL; error = NULL; \
|
|
||||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
|
||||||
free(field); field = val ? strdup(val) : strdup(""); \
|
|
||||||
g_free(val); val = NULL; \
|
|
||||||
} else { \
|
|
||||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
|
||||||
error ? error->message : "unknown"); \
|
|
||||||
if (error) { g_error_free(error); error = NULL; } \
|
|
||||||
}
|
|
||||||
|
|
||||||
LOAD("CustomServer", cfg_custom_server)
|
|
||||||
LOAD("AuthKey", cfg_auth_key)
|
|
||||||
#undef LOAD
|
|
||||||
}
|
|
||||||
|
|
||||||
static void write_config_file(void) {
|
|
||||||
FILE *f = fopen(CONFIG_FILE, "w");
|
|
||||||
if (!f) {
|
|
||||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
|
||||||
CONFIG_FILE, strerror(errno));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
|
||||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
|
||||||
fclose(f);
|
|
||||||
chmod(CONFIG_FILE, 0600);
|
|
||||||
syslog(LOG_INFO, "config updated: server=%s",
|
|
||||||
cache_get(&cfg_custom_server, "(default)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
|
||||||
reload_timer_id = 0;
|
|
||||||
if (g_ax_handle)
|
|
||||||
load_config_cache(g_ax_handle);
|
|
||||||
write_config_file();
|
|
||||||
syslog(LOG_INFO, "restarting with new config");
|
|
||||||
stop_child();
|
|
||||||
start_child();
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void parameter_changed(const gchar *name, const gchar *value,
|
|
||||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
|
||||||
const char *dot = strrchr(name, '.');
|
|
||||||
const char *short_name = dot ? dot + 1 : name;
|
|
||||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
|
||||||
|
|
||||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
|
||||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
|
||||||
|
|
||||||
if (reload_timer_id)
|
|
||||||
g_source_remove(reload_timer_id);
|
|
||||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
static gboolean signal_handler(gpointer loop) {
|
|
||||||
syslog(LOG_INFO, "stopping");
|
|
||||||
stop_child();
|
|
||||||
g_main_loop_quit((GMainLoop *)loop);
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void) {
|
|
||||||
GError *error = NULL;
|
|
||||||
|
|
||||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
|
||||||
syslog(LOG_INFO, "starting (root mode)");
|
|
||||||
|
|
||||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
|
||||||
|
|
||||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
|
||||||
if (!handle) {
|
|
||||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
|
||||||
error ? error->message : "unknown");
|
|
||||||
if (error) g_error_free(error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
g_ax_handle = handle;
|
|
||||||
|
|
||||||
load_config_cache(handle);
|
|
||||||
write_config_file();
|
|
||||||
start_child();
|
|
||||||
|
|
||||||
const char *params[] = { "CustomServer", "AuthKey" };
|
|
||||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
|
||||||
if (!ax_parameter_register_callback(handle, params[i],
|
|
||||||
parameter_changed, handle, &error)) {
|
|
||||||
syslog(LOG_WARNING, "register callback %s: %s",
|
|
||||||
params[i], error ? error->message : "unknown");
|
|
||||||
if (error) { g_error_free(error); error = NULL; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
|
||||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
|
||||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
|
||||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
|
||||||
|
|
||||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
|
||||||
g_main_loop_run(loop);
|
|
||||||
|
|
||||||
g_main_loop_unref(loop);
|
|
||||||
ax_parameter_free(handle);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
+5
-4
@@ -1,13 +1,14 @@
|
|||||||
ARG ARCH=armv7hf
|
ARG ARCH=armv7hf
|
||||||
ARG VERSION=1.15.1
|
ARG VERSION=12.10.0
|
||||||
ARG UBUNTU_VERSION=22.04
|
ARG UBUNTU_VERSION=24.04
|
||||||
ARG REPO=axisecp
|
ARG REPO=axisecp
|
||||||
ARG SDK=acap-native-sdk
|
ARG SDK=acap-native-sdk
|
||||||
|
|
||||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||||
|
|
||||||
# Building the ACAP application
|
COPY common/app /opt/app/
|
||||||
COPY ./app /opt/app/
|
COPY arm/app/manifest.json /opt/app/manifest.json
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
|
ENV EXTRA_CFLAGS=-DHAS_PROXY_PORTS
|
||||||
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
||||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
To build from main directory
|
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
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,76 +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).
|
|
||||||
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
|
|
||||||
@@ -1,852 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Tailscale VPN</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #0f1117;
|
|
||||||
--surface: #181b23;
|
|
||||||
--surface2: #1e2230;
|
|
||||||
--border: #262a35;
|
|
||||||
--text: #e4e6ed;
|
|
||||||
--muted: #8b8fa3;
|
|
||||||
--accent: #2e2d2d;
|
|
||||||
--green: #22c55e;
|
|
||||||
--yellow: #f59e0b;
|
|
||||||
--red: #ef4444;
|
|
||||||
--radius: 10px;
|
|
||||||
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] {
|
|
||||||
--bg: #f5f6f8;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface2: #f0f1f4;
|
|
||||||
--border: #e0e3e8;
|
|
||||||
--text: #1a1a2e;
|
|
||||||
--muted: #6b7084;
|
|
||||||
--accent: #2e2d2d;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.theme-btn {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
|
|
||||||
.theme-btn svg { width: 16px; height: 16px; }
|
|
||||||
|
|
||||||
/* Cards */
|
|
||||||
.card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status */
|
|
||||||
.status-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
|
|
||||||
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
|
|
||||||
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
|
|
||||||
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
|
|
||||||
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.status-banner.connected .status-text { color: var(--green); }
|
|
||||||
.status-banner.connecting .status-text { color: var(--yellow); }
|
|
||||||
.status-banner.disconnected .status-text { color: var(--red); }
|
|
||||||
|
|
||||||
.status-time {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Auth block */
|
|
||||||
.auth-block {
|
|
||||||
background: rgba(245,158,11,0.08);
|
|
||||||
border: 1px solid rgba(245,158,11,0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.auth-block p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.auth-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 8px 18px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.auth-btn:hover { opacity: 0.9; }
|
|
||||||
.auth-url {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
word-break: break-all;
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info grid */
|
|
||||||
.info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.info-item {
|
|
||||||
background: var(--surface2);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
.info-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.info-value {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--mono);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.info-value.dim { color: var(--muted); font-weight: 400; }
|
|
||||||
|
|
||||||
/* Log viewer */
|
|
||||||
.log-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.log-badge {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
.log-toggle {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.log-toggle:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.log-box {
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 11.5px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.log-box .log-line { display: block; }
|
|
||||||
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
|
|
||||||
.log-line .ts { color: var(--muted); opacity: 0.6; }
|
|
||||||
.log-line .msg-info { color: var(--accent); }
|
|
||||||
.log-line .msg-warn { color: var(--yellow); }
|
|
||||||
.log-line .msg-err { color: var(--red); }
|
|
||||||
.log-line .msg-ok { color: var(--green); }
|
|
||||||
|
|
||||||
/* Settings form */
|
|
||||||
.settings-form { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.settings-row { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
|
|
||||||
.settings-input {
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
padding: 8px 10px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.settings-input:focus { border-color: var(--accent); }
|
|
||||||
.settings-hint { font-size: 11px; color: var(--muted); }
|
|
||||||
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
|
|
||||||
.save-btn {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 18px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.save-btn:hover { opacity: 0.9; }
|
|
||||||
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.save-status { font-size: 12px; color: var(--muted); }
|
|
||||||
.save-status.ok { color: var(--green); }
|
|
||||||
.save-status.err { color: var(--red); }
|
|
||||||
|
|
||||||
/* Refresh indicator */
|
|
||||||
.refresh-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Update banner */
|
|
||||||
.update-banner {
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
background: rgba(46,45,45,0.1);
|
|
||||||
border: 1px solid rgba(46,45,45,0.2);
|
|
||||||
}
|
|
||||||
.update-banner.visible { display: flex; }
|
|
||||||
.update-banner .update-text {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.update-banner .update-text strong { color: var(--accent); }
|
|
||||||
.update-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.update-btn:hover { opacity: 0.9; }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
body { padding: 14px; }
|
|
||||||
.info-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1>Tailscale VPN</h1>
|
|
||||||
</div>
|
|
||||||
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
|
|
||||||
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
|
||||||
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div id="status-banner" class="status-banner connecting">
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span id="status-text" class="status-text">Checking...</span>
|
|
||||||
<span id="status-time" class="status-time"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Update available -->
|
|
||||||
<div id="update-banner" class="update-banner">
|
|
||||||
<div class="update-text">Update available: <strong id="update-version"></strong></div>
|
|
||||||
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
|
||||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auth (hidden by default) -->
|
|
||||||
<div id="auth-block" class="auth-block" style="display:none;">
|
|
||||||
<p>Authenticate this device to connect to your Tailscale network:</p>
|
|
||||||
<a id="auth-link" class="auth-btn" href="#" target="_blank">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
||||||
Open Login Page
|
|
||||||
</a>
|
|
||||||
<span id="auth-url-text" class="auth-url"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connection Info -->
|
|
||||||
<div class="card" id="info-card" style="display:none;">
|
|
||||||
<div class="card-title">Connection Details</div>
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Tailscale IP</div>
|
|
||||||
<div class="info-value" id="ts-ip">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Node Name</div>
|
|
||||||
<div class="info-value" id="ts-node">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Account</div>
|
|
||||||
<div class="info-value" id="ts-tailnet">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Version</div>
|
|
||||||
<div class="info-value" id="ts-version">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:14px;text-align:right;">
|
|
||||||
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Proxy Info (always visible) -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">Proxy Configuration</div>
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">HTTP/HTTPS Proxy</div>
|
|
||||||
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">SOCKS5 Proxy</div>
|
|
||||||
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">Settings</div>
|
|
||||||
<div class="settings-form">
|
|
||||||
<div class="settings-row">
|
|
||||||
<label class="settings-label" for="input-server">Custom Server URL</label>
|
|
||||||
<input class="settings-input" id="input-server" type="text" 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLogs(txt) {
|
|
||||||
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
|
|
||||||
document.getElementById('log-count').textContent = lines.length + ' lines';
|
|
||||||
|
|
||||||
var h = '';
|
|
||||||
for (var i = 0; i < lines.length; i++) {
|
|
||||||
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
|
|
||||||
var cls = classifyLine(lines[i]);
|
|
||||||
if (parts) {
|
|
||||||
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
|
|
||||||
} else {
|
|
||||||
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logBox.innerHTML = h;
|
|
||||||
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(r) {
|
|
||||||
var banner = document.getElementById('status-banner');
|
|
||||||
var statusText = document.getElementById('status-text');
|
|
||||||
var auth = document.getElementById('auth-block');
|
|
||||||
var info = document.getElementById('info-card');
|
|
||||||
|
|
||||||
banner.className = 'status-banner ' + r.state;
|
|
||||||
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
|
|
||||||
statusText.textContent = labels[r.state];
|
|
||||||
|
|
||||||
if (r.state === 'connecting' && r.url) {
|
|
||||||
document.getElementById('auth-link').href = r.url;
|
|
||||||
document.getElementById('auth-url-text').textContent = r.url;
|
|
||||||
auth.style.display = '';
|
|
||||||
} else {
|
|
||||||
auth.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
Binary file not shown.
Binary file not shown.
+63
-36
@@ -1,40 +1,67 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "1.7.0",
|
"schemaVersion": "2.0.0",
|
||||||
"acapPackageConf": {
|
"acapPackageConf": {
|
||||||
"setup": {
|
"setup": {
|
||||||
"appName": "Tailscale_VPN",
|
"appName": "Tailscale_VPN",
|
||||||
"friendlyName": "Tailscale VPN",
|
"friendlyName": "Tailscale VPN",
|
||||||
"vendor": "Mo3he",
|
"vendor": "moshe@mohome.net",
|
||||||
"embeddedSdkVersion": "3.0",
|
"vendorId": "70ee172dd9",
|
||||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||||
"runMode": "respawn",
|
"version": "1.102.4",
|
||||||
"version": "1.96.4",
|
"architecture": "armv7hf",
|
||||||
"architecture": "armv7hf"
|
"runMode": "respawn",
|
||||||
},
|
"compatibleOsVersions": [
|
||||||
"configuration": {
|
{
|
||||||
"settingPage": "index.html",
|
"max": "13"
|
||||||
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
// Copyright (C) 2024 Mo3he
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACAP parameter bridge for Tailscale VPN (userspace variant).
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
|
|
||||||
* 2. Write them to CONFIG_FILE so the shell script can source them.
|
|
||||||
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
|
|
||||||
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
|
|
||||||
* of the child so the new config is picked up.
|
|
||||||
* Rapid changes within 300 ms are coalesced into a single restart.
|
|
||||||
* 5. Watchdog: if the child exits unexpectedly, restart it.
|
|
||||||
*
|
|
||||||
* Runs as the unprivileged 'sdk' ACAP user — no root required.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <axsdk/axparameter.h>
|
|
||||||
#include <glib-unix.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <syslog.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include <sys/types.h>
|
|
||||||
#include <sys/wait.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <signal.h>
|
|
||||||
|
|
||||||
#define APP_NAME "Tailscale_VPN"
|
|
||||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
|
||||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
|
||||||
|
|
||||||
static pid_t child_pid = -1;
|
|
||||||
static guint reload_timer_id = 0;
|
|
||||||
static AXParameter *g_ax_handle = NULL;
|
|
||||||
|
|
||||||
static char *cfg_custom_server = NULL;
|
|
||||||
static char *cfg_auth_key = NULL;
|
|
||||||
static char *cfg_http_proxy_port = NULL;
|
|
||||||
static char *cfg_socks5_port = NULL;
|
|
||||||
|
|
||||||
static void cache_set(char **field, const char *value) {
|
|
||||||
if (!value) return;
|
|
||||||
free(*field);
|
|
||||||
*field = strdup(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const char *cache_get(char **field, const char *fallback) {
|
|
||||||
return (*field && **field) ? *field : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── child process management ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static void stop_child(void) {
|
|
||||||
if (child_pid <= 0)
|
|
||||||
return;
|
|
||||||
kill(child_pid, SIGTERM);
|
|
||||||
for (int i = 0; i < 30; i++) {
|
|
||||||
int status;
|
|
||||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
|
||||||
child_pid = -1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
usleep(100000);
|
|
||||||
}
|
|
||||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
|
||||||
kill(child_pid, SIGKILL);
|
|
||||||
waitpid(child_pid, NULL, 0);
|
|
||||||
child_pid = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void start_child(void) {
|
|
||||||
stop_child();
|
|
||||||
pid_t pid = fork();
|
|
||||||
if (pid < 0) {
|
|
||||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pid == 0) {
|
|
||||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
|
||||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
|
||||||
_exit(1);
|
|
||||||
}
|
|
||||||
child_pid = pid;
|
|
||||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── watchdog ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
|
||||||
if (child_pid > 0) {
|
|
||||||
int status;
|
|
||||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
|
||||||
if (ret == child_pid) {
|
|
||||||
int exit_code = WEXITSTATUS(status);
|
|
||||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
|
||||||
child_pid = -1;
|
|
||||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
|
||||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
|
||||||
GError *err = NULL;
|
|
||||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
|
||||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
|
||||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
|
||||||
} else {
|
|
||||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
|
||||||
err ? err->message : "unknown");
|
|
||||||
if (err) g_error_free(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
start_child();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return G_SOURCE_CONTINUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── config file ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static void load_config_cache(AXParameter *handle) {
|
|
||||||
GError *error = NULL;
|
|
||||||
gchar *val = NULL;
|
|
||||||
|
|
||||||
#define LOAD(name, field) \
|
|
||||||
val = NULL; error = NULL; \
|
|
||||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
|
||||||
free(field); field = val ? strdup(val) : strdup(""); \
|
|
||||||
g_free(val); val = NULL; \
|
|
||||||
} else { \
|
|
||||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
|
||||||
error ? error->message : "unknown"); \
|
|
||||||
if (error) { g_error_free(error); error = NULL; } \
|
|
||||||
}
|
|
||||||
|
|
||||||
LOAD("CustomServer", cfg_custom_server)
|
|
||||||
LOAD("AuthKey", cfg_auth_key)
|
|
||||||
LOAD("HttpProxyPort", cfg_http_proxy_port)
|
|
||||||
LOAD("Socks5Port", cfg_socks5_port)
|
|
||||||
#undef LOAD
|
|
||||||
}
|
|
||||||
|
|
||||||
static void write_config_file(void) {
|
|
||||||
FILE *f = fopen(CONFIG_FILE, "w");
|
|
||||||
if (!f) {
|
|
||||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
|
||||||
CONFIG_FILE, strerror(errno));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
|
||||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
|
||||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
|
||||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
|
||||||
fclose(f);
|
|
||||||
chmod(CONFIG_FILE, 0600);
|
|
||||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
|
||||||
cache_get(&cfg_http_proxy_port, "8080"),
|
|
||||||
cache_get(&cfg_socks5_port, "1080"),
|
|
||||||
cache_get(&cfg_custom_server, "(default)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
|
||||||
reload_timer_id = 0;
|
|
||||||
if (g_ax_handle)
|
|
||||||
load_config_cache(g_ax_handle);
|
|
||||||
write_config_file();
|
|
||||||
syslog(LOG_INFO, "restarting with new config");
|
|
||||||
stop_child();
|
|
||||||
start_child();
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void parameter_changed(const gchar *name, const gchar *value,
|
|
||||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
|
||||||
const char *dot = strrchr(name, '.');
|
|
||||||
const char *short_name = dot ? dot + 1 : name;
|
|
||||||
|
|
||||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
|
||||||
|
|
||||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
|
||||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
|
||||||
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
|
|
||||||
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
|
|
||||||
|
|
||||||
if (reload_timer_id)
|
|
||||||
g_source_remove(reload_timer_id);
|
|
||||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── signal handler ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
static gboolean signal_handler(gpointer loop) {
|
|
||||||
syslog(LOG_INFO, "stopping");
|
|
||||||
stop_child();
|
|
||||||
g_main_loop_quit((GMainLoop *)loop);
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── main ────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
int main(void) {
|
|
||||||
GError *error = NULL;
|
|
||||||
|
|
||||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
|
||||||
syslog(LOG_INFO, "starting");
|
|
||||||
|
|
||||||
/* Ensure localdata dir exists */
|
|
||||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
|
||||||
|
|
||||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
|
||||||
if (!handle) {
|
|
||||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
|
||||||
error ? error->message : "unknown");
|
|
||||||
if (error) g_error_free(error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
g_ax_handle = handle;
|
|
||||||
|
|
||||||
load_config_cache(handle);
|
|
||||||
write_config_file();
|
|
||||||
start_child();
|
|
||||||
|
|
||||||
const char *params[] = {
|
|
||||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port"
|
|
||||||
};
|
|
||||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
|
||||||
if (!ax_parameter_register_callback(handle, params[i],
|
|
||||||
parameter_changed, handle, &error)) {
|
|
||||||
syslog(LOG_WARNING, "register callback %s: %s",
|
|
||||||
params[i], error ? error->message : "unknown");
|
|
||||||
if (error) { g_error_free(error); error = NULL; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
|
||||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
|
||||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
|
||||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
|
||||||
|
|
||||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
|
||||||
g_main_loop_run(loop);
|
|
||||||
|
|
||||||
g_main_loop_unref(loop);
|
|
||||||
ax_parameter_free(handle);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
+2
-2
@@ -6,8 +6,8 @@ ARG SDK=acap-native-sdk
|
|||||||
|
|
||||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||||
|
|
||||||
# Building the ACAP application
|
COPY common/app /opt/app/
|
||||||
COPY ./app /opt/app/
|
COPY arm_ROOT/app/manifest.json /opt/app/manifest.json
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
||||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
To build from main directory
|
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
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,852 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Tailscale VPN</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #0f1117;
|
|
||||||
--surface: #181b23;
|
|
||||||
--surface2: #1e2230;
|
|
||||||
--border: #262a35;
|
|
||||||
--text: #e4e6ed;
|
|
||||||
--muted: #8b8fa3;
|
|
||||||
--accent: #2e2d2d;
|
|
||||||
--green: #22c55e;
|
|
||||||
--yellow: #f59e0b;
|
|
||||||
--red: #ef4444;
|
|
||||||
--radius: 10px;
|
|
||||||
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] {
|
|
||||||
--bg: #f5f6f8;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface2: #f0f1f4;
|
|
||||||
--border: #e0e3e8;
|
|
||||||
--text: #1a1a2e;
|
|
||||||
--muted: #6b7084;
|
|
||||||
--accent: #2e2d2d;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.theme-btn {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
|
|
||||||
.theme-btn svg { width: 16px; height: 16px; }
|
|
||||||
|
|
||||||
/* Cards */
|
|
||||||
.card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status */
|
|
||||||
.status-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
|
|
||||||
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
|
|
||||||
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
|
|
||||||
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
|
|
||||||
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.status-banner.connected .status-text { color: var(--green); }
|
|
||||||
.status-banner.connecting .status-text { color: var(--yellow); }
|
|
||||||
.status-banner.disconnected .status-text { color: var(--red); }
|
|
||||||
|
|
||||||
.status-time {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Auth block */
|
|
||||||
.auth-block {
|
|
||||||
background: rgba(245,158,11,0.08);
|
|
||||||
border: 1px solid rgba(245,158,11,0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.auth-block p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.auth-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 8px 18px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.auth-btn:hover { opacity: 0.9; }
|
|
||||||
.auth-url {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
word-break: break-all;
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info grid */
|
|
||||||
.info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.info-item {
|
|
||||||
background: var(--surface2);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
.info-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.info-value {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--mono);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.info-value.dim { color: var(--muted); font-weight: 400; }
|
|
||||||
|
|
||||||
/* Log viewer */
|
|
||||||
.log-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.log-badge {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
.log-toggle {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.log-toggle:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.log-box {
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 11.5px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.log-box .log-line { display: block; }
|
|
||||||
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
|
|
||||||
.log-line .ts { color: var(--muted); opacity: 0.6; }
|
|
||||||
.log-line .msg-info { color: var(--accent); }
|
|
||||||
.log-line .msg-warn { color: var(--yellow); }
|
|
||||||
.log-line .msg-err { color: var(--red); }
|
|
||||||
.log-line .msg-ok { color: var(--green); }
|
|
||||||
|
|
||||||
/* Settings form */
|
|
||||||
.settings-form { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.settings-row { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.settings-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
|
|
||||||
.settings-input {
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
padding: 8px 10px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.settings-input:focus { border-color: var(--accent); }
|
|
||||||
.settings-hint { font-size: 11px; color: var(--muted); }
|
|
||||||
.settings-actions { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 4px; }
|
|
||||||
.save-btn {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 18px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.save-btn:hover { opacity: 0.9; }
|
|
||||||
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.save-status { font-size: 12px; color: var(--muted); }
|
|
||||||
.save-status.ok { color: var(--green); }
|
|
||||||
.save-status.err { color: var(--red); }
|
|
||||||
|
|
||||||
/* Refresh indicator */
|
|
||||||
.refresh-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Update banner */
|
|
||||||
.update-banner {
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
background: rgba(46,45,45,0.1);
|
|
||||||
border: 1px solid rgba(46,45,45,0.2);
|
|
||||||
}
|
|
||||||
.update-banner.visible { display: flex; }
|
|
||||||
.update-banner .update-text {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.update-banner .update-text strong { color: var(--accent); }
|
|
||||||
.update-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.update-btn:hover { opacity: 0.9; }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
body { padding: 14px; }
|
|
||||||
.info-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1>Tailscale VPN</h1>
|
|
||||||
</div>
|
|
||||||
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
|
|
||||||
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
|
||||||
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div id="status-banner" class="status-banner connecting">
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span id="status-text" class="status-text">Checking...</span>
|
|
||||||
<span id="status-time" class="status-time"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Update available -->
|
|
||||||
<div id="update-banner" class="update-banner">
|
|
||||||
<div class="update-text">Update available: <strong id="update-version"></strong></div>
|
|
||||||
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
|
||||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auth (hidden by default) -->
|
|
||||||
<div id="auth-block" class="auth-block" style="display:none;">
|
|
||||||
<p>Authenticate this device to connect to your Tailscale network:</p>
|
|
||||||
<a id="auth-link" class="auth-btn" href="#" target="_blank">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
||||||
Open Login Page
|
|
||||||
</a>
|
|
||||||
<span id="auth-url-text" class="auth-url"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connection Info -->
|
|
||||||
<div class="card" id="info-card" style="display:none;">
|
|
||||||
<div class="card-title">Connection Details</div>
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Tailscale IP</div>
|
|
||||||
<div class="info-value" id="ts-ip">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Node Name</div>
|
|
||||||
<div class="info-value" id="ts-node">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Account</div>
|
|
||||||
<div class="info-value" id="ts-tailnet">-</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Version</div>
|
|
||||||
<div class="info-value" id="ts-version">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:14px;text-align:right;">
|
|
||||||
<button id="check-update-btn" class="log-toggle">Check for Updates</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Proxy Info (always visible) -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">Proxy Configuration</div>
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">HTTP/HTTPS Proxy</div>
|
|
||||||
<div class="info-value" id="ts-http-proxy">http://127.0.0.1:8080</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">SOCKS5 Proxy</div>
|
|
||||||
<div class="info-value" id="ts-socks-proxy">127.0.0.1:1080</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">Settings</div>
|
|
||||||
<div class="settings-form">
|
|
||||||
<div class="settings-row">
|
|
||||||
<label class="settings-label" for="input-server">Custom Server URL</label>
|
|
||||||
<input class="settings-input" id="input-server" type="text" 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLogs(txt) {
|
|
||||||
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
|
|
||||||
document.getElementById('log-count').textContent = lines.length + ' lines';
|
|
||||||
|
|
||||||
var h = '';
|
|
||||||
for (var i = 0; i < lines.length; i++) {
|
|
||||||
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
|
|
||||||
var cls = classifyLine(lines[i]);
|
|
||||||
if (parts) {
|
|
||||||
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
|
|
||||||
} else {
|
|
||||||
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logBox.innerHTML = h;
|
|
||||||
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(r) {
|
|
||||||
var banner = document.getElementById('status-banner');
|
|
||||||
var statusText = document.getElementById('status-text');
|
|
||||||
var auth = document.getElementById('auth-block');
|
|
||||||
var info = document.getElementById('info-card');
|
|
||||||
|
|
||||||
banner.className = 'status-banner ' + r.state;
|
|
||||||
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
|
|
||||||
statusText.textContent = labels[r.state];
|
|
||||||
|
|
||||||
if (r.state === 'connecting' && r.url) {
|
|
||||||
document.getElementById('auth-link').href = r.url;
|
|
||||||
document.getElementById('auth-url-text').textContent = r.url;
|
|
||||||
auth.style.display = '';
|
|
||||||
} else {
|
|
||||||
auth.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
Binary file not shown.
Binary file not shown.
+52
-30
@@ -1,34 +1,56 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "1.7.0",
|
"schemaVersion": "1.7.0",
|
||||||
"acapPackageConf": {
|
"acapPackageConf": {
|
||||||
"setup": {
|
"setup": {
|
||||||
"appName": "Tailscale_VPN",
|
"appName": "Tailscale_VPN",
|
||||||
"friendlyName": "Tailscale VPN",
|
"friendlyName": "Tailscale VPN",
|
||||||
"vendor": "Mo3he",
|
"vendor": "Mo3he",
|
||||||
"embeddedSdkVersion": "3.0",
|
"embeddedSdkVersion": "3.0",
|
||||||
"user": {
|
"user": {
|
||||||
"group": "root",
|
"group": "root",
|
||||||
"username": "root"
|
"username": "root"
|
||||||
},
|
},
|
||||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||||
"runMode": "respawn",
|
"runMode": "respawn",
|
||||||
"version": "1.96.4",
|
"version": "1.102.4",
|
||||||
"architecture": "armv7hf"
|
"architecture": "armv7hf"
|
||||||
},
|
},
|
||||||
"configuration": {
|
"configuration": {
|
||||||
"settingPage": "index.html",
|
"settingPage": "index.html",
|
||||||
"paramConfig": [
|
"reverseProxy": [
|
||||||
{
|
{
|
||||||
"name": "CustomServer",
|
"apiPath": "api",
|
||||||
"default": "",
|
"target": "http://localhost:2201/",
|
||||||
"type": "string"
|
"access": "admin"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
// Copyright (C) 2024 Mo3he
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
|
|
||||||
* Same structure as regular param_bridge.c but without proxy port params.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <axsdk/axparameter.h>
|
|
||||||
#include <glib-unix.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <syslog.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include <sys/types.h>
|
|
||||||
#include <sys/wait.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <signal.h>
|
|
||||||
|
|
||||||
#define APP_NAME "Tailscale_VPN"
|
|
||||||
#define CONFIG_FILE "/usr/local/packages/Tailscale_VPN/localdata/params.conf"
|
|
||||||
#define RUN_SCRIPT "/usr/local/packages/Tailscale_VPN/Tailscale_VPN_run"
|
|
||||||
|
|
||||||
static pid_t child_pid = -1;
|
|
||||||
static guint reload_timer_id = 0;
|
|
||||||
static AXParameter *g_ax_handle = NULL;
|
|
||||||
|
|
||||||
static char *cfg_custom_server = NULL;
|
|
||||||
static char *cfg_auth_key = NULL;
|
|
||||||
|
|
||||||
static void cache_set(char **field, const char *value) {
|
|
||||||
if (!value) return;
|
|
||||||
free(*field);
|
|
||||||
*field = strdup(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const char *cache_get(char **field, const char *fallback) {
|
|
||||||
return (*field && **field) ? *field : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void stop_child(void) {
|
|
||||||
if (child_pid <= 0)
|
|
||||||
return;
|
|
||||||
kill(child_pid, SIGTERM);
|
|
||||||
for (int i = 0; i < 30; i++) {
|
|
||||||
int status;
|
|
||||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
|
||||||
child_pid = -1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
usleep(100000);
|
|
||||||
}
|
|
||||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
|
||||||
kill(child_pid, SIGKILL);
|
|
||||||
waitpid(child_pid, NULL, 0);
|
|
||||||
child_pid = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void start_child(void) {
|
|
||||||
stop_child();
|
|
||||||
pid_t pid = fork();
|
|
||||||
if (pid < 0) {
|
|
||||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pid == 0) {
|
|
||||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
|
||||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
|
||||||
_exit(1);
|
|
||||||
}
|
|
||||||
child_pid = pid;
|
|
||||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
|
||||||
}
|
|
||||||
|
|
||||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
|
||||||
if (child_pid > 0) {
|
|
||||||
int status;
|
|
||||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
|
||||||
if (ret == child_pid) {
|
|
||||||
int exit_code = WEXITSTATUS(status);
|
|
||||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
|
||||||
child_pid = -1;
|
|
||||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
|
||||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
|
||||||
GError *err = NULL;
|
|
||||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
|
||||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
|
||||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
|
||||||
} else {
|
|
||||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
|
||||||
err ? err->message : "unknown");
|
|
||||||
if (err) g_error_free(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
start_child();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return G_SOURCE_CONTINUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void load_config_cache(AXParameter *handle) {
|
|
||||||
GError *error = NULL;
|
|
||||||
gchar *val = NULL;
|
|
||||||
|
|
||||||
#define LOAD(name, field) \
|
|
||||||
val = NULL; error = NULL; \
|
|
||||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
|
||||||
free(field); field = val ? strdup(val) : strdup(""); \
|
|
||||||
g_free(val); val = NULL; \
|
|
||||||
} else { \
|
|
||||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
|
||||||
error ? error->message : "unknown"); \
|
|
||||||
if (error) { g_error_free(error); error = NULL; } \
|
|
||||||
}
|
|
||||||
|
|
||||||
LOAD("CustomServer", cfg_custom_server)
|
|
||||||
LOAD("AuthKey", cfg_auth_key)
|
|
||||||
#undef LOAD
|
|
||||||
}
|
|
||||||
|
|
||||||
static void write_config_file(void) {
|
|
||||||
FILE *f = fopen(CONFIG_FILE, "w");
|
|
||||||
if (!f) {
|
|
||||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
|
||||||
CONFIG_FILE, strerror(errno));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
|
||||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
|
||||||
fclose(f);
|
|
||||||
chmod(CONFIG_FILE, 0600);
|
|
||||||
syslog(LOG_INFO, "config updated: server=%s",
|
|
||||||
cache_get(&cfg_custom_server, "(default)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
|
||||||
reload_timer_id = 0;
|
|
||||||
if (g_ax_handle)
|
|
||||||
load_config_cache(g_ax_handle);
|
|
||||||
write_config_file();
|
|
||||||
syslog(LOG_INFO, "restarting with new config");
|
|
||||||
stop_child();
|
|
||||||
start_child();
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void parameter_changed(const gchar *name, const gchar *value,
|
|
||||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
|
||||||
const char *dot = strrchr(name, '.');
|
|
||||||
const char *short_name = dot ? dot + 1 : name;
|
|
||||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
|
||||||
|
|
||||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
|
||||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
|
||||||
|
|
||||||
if (reload_timer_id)
|
|
||||||
g_source_remove(reload_timer_id);
|
|
||||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
static gboolean signal_handler(gpointer loop) {
|
|
||||||
syslog(LOG_INFO, "stopping");
|
|
||||||
stop_child();
|
|
||||||
g_main_loop_quit((GMainLoop *)loop);
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void) {
|
|
||||||
GError *error = NULL;
|
|
||||||
|
|
||||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
|
||||||
syslog(LOG_INFO, "starting (root mode)");
|
|
||||||
|
|
||||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
|
||||||
|
|
||||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
|
||||||
if (!handle) {
|
|
||||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
|
||||||
error ? error->message : "unknown");
|
|
||||||
if (error) g_error_free(error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
g_ax_handle = handle;
|
|
||||||
|
|
||||||
load_config_cache(handle);
|
|
||||||
write_config_file();
|
|
||||||
start_child();
|
|
||||||
|
|
||||||
const char *params[] = { "CustomServer", "AuthKey" };
|
|
||||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
|
||||||
if (!ax_parameter_register_callback(handle, params[i],
|
|
||||||
parameter_changed, handle, &error)) {
|
|
||||||
syslog(LOG_WARNING, "register callback %s: %s",
|
|
||||||
params[i], error ? error->message : "unknown");
|
|
||||||
if (error) { g_error_free(error); error = NULL; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GMainLoop *loop = g_main_loop_new(NULL, FALSE);
|
|
||||||
g_unix_signal_add(SIGTERM, signal_handler, loop);
|
|
||||||
g_unix_signal_add(SIGINT, signal_handler, loop);
|
|
||||||
g_timeout_add_seconds(60, watchdog_cb, NULL);
|
|
||||||
|
|
||||||
syslog(LOG_INFO, "running — watching for parameter changes");
|
|
||||||
g_main_loop_run(loop);
|
|
||||||
|
|
||||||
g_main_loop_unref(loop);
|
|
||||||
ax_parameter_free(handle);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
+9
-13
@@ -4,21 +4,18 @@ FROM axisecp/acap-sdk:3.5-armv7hf-ubuntu${UBUNTU_VERSION}
|
|||||||
RUN apt-get update -qq && apt-get install -y --no-install-recommends upx-ucl && \
|
RUN apt-get update -qq && apt-get install -y --no-install-recommends upx-ucl && \
|
||||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY ./app /opt/app/
|
COPY arm_acap3/app /opt/app/
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
|
|
||||||
# Rename the shell startup script (the ELF launcher will take the Tailscale_VPN name)
|
# The ELF launcher takes over the Tailscale_VPN name.
|
||||||
RUN mv Tailscale_VPN start.sh && chmod +x start.sh
|
RUN mv Tailscale_VPN start.sh && chmod +x start.sh
|
||||||
|
|
||||||
# Compile a minimal ELF launcher as APPNAME.
|
# ACAP 3 elflibcheck needs APPNAME to be ELF and uses pidof(APPNAME) for status.
|
||||||
# ACAP 3 elflibcheck requires an ELF binary and uses pidof(APPNAME) for status.
|
|
||||||
# We fork+exec start.sh so the parent "Tailscale_VPN" process stays resident,
|
|
||||||
# making pidof find it and the camera UI correctly show Running/Stopped.
|
|
||||||
RUN . /opt/axis/acapsdk/environment-setup* && \
|
RUN . /opt/axis/acapsdk/environment-setup* && \
|
||||||
${CC} -o Tailscale_VPN launcher.c && \
|
${CC} -o Tailscale_VPN launcher.c && \
|
||||||
${STRIP} -s Tailscale_VPN
|
${STRIP} -s Tailscale_VPN
|
||||||
|
|
||||||
# Strip then UPX-compress the Tailscale binaries so they fit on flash
|
# UPX so the binaries fit on flash
|
||||||
RUN . /opt/axis/acapsdk/environment-setup* && \
|
RUN . /opt/axis/acapsdk/environment-setup* && \
|
||||||
${STRIP} -s lib/tailscale lib/tailscaled 2>/dev/null || true && \
|
${STRIP} -s lib/tailscale lib/tailscaled 2>/dev/null || true && \
|
||||||
upx --best lib/tailscale lib/tailscaled
|
upx --best lib/tailscale lib/tailscaled
|
||||||
@@ -26,16 +23,15 @@ RUN . /opt/axis/acapsdk/environment-setup* && \
|
|||||||
# ACAP 3 firmware expects the settings page at the app root, not in html/
|
# ACAP 3 firmware expects the settings page at the app root, not in html/
|
||||||
RUN cp html/index.html index.html
|
RUN cp html/index.html index.html
|
||||||
|
|
||||||
# Symlink tailscaled.log into html/ so the web UI can fetch it via HTTP.
|
# Runtime files live in localdata/; symlink them so the web UI can fetch them.
|
||||||
# The log is written at runtime to localdata/ (resolved path at runtime).
|
|
||||||
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log
|
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log
|
||||||
|
|
||||||
# Build and package
|
RUN ln -sf ../localdata/status.json html/status.json
|
||||||
|
|
||||||
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
|
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
|
||||||
|
|
||||||
# Patch STARTMODE: create-package.sh hardcodes "never" unless RESTRICTION_STARTMODE is set,
|
# create-package.sh hardcodes STARTMODE="never" (ignoring package.conf) unless
|
||||||
# but the ACAP 3 SDK does not honour our package.conf's STARTMODE=respawn without it.
|
# RESTRICTION_STARTMODE is set, so repack the .eap with respawn.
|
||||||
# Repack the .eap with the corrected value.
|
|
||||||
RUN for eap in *.eap; do \
|
RUN for eap in *.eap; do \
|
||||||
tmpdir=$(mktemp -d) && tar xf "$eap" -C "$tmpdir" && \
|
tmpdir=$(mktemp -d) && tar xf "$eap" -C "$tmpdir" && \
|
||||||
sed -i 's/STARTMODE="never"/STARTMODE="respawn"/' "$tmpdir/package.conf" && \
|
sed -i 's/STARTMODE="never"/STARTMODE="respawn"/' "$tmpdir/package.conf" && \
|
||||||
|
|||||||
+42
-16
@@ -16,19 +16,19 @@ logger -t "Tailscale_VPN" "Starting tailscaled daemon"
|
|||||||
# Log to file (not piped through logger) -- avoids extra logger process holding
|
# Log to file (not piped through logger) -- avoids extra logger process holding
|
||||||
# tailscaled stdout open, which prevents our wait loop from detecting exit
|
# tailscaled stdout open, which prevents our wait loop from detecting exit
|
||||||
"$APP_DIR/lib/tailscaled" \
|
"$APP_DIR/lib/tailscaled" \
|
||||||
--state="$STATE_DIR/tailscaled.state" \
|
--state="$STATE_DIR/tailscaled.state" \
|
||||||
--socket="$STATE_DIR/tailscaled.sock" \
|
--socket="$STATE_DIR/tailscaled.sock" \
|
||||||
--socks5-server=localhost:1055 \
|
--socks5-server=localhost:1055 \
|
||||||
--outbound-http-proxy-listen=localhost:8080 \
|
--outbound-http-proxy-listen=localhost:8080 \
|
||||||
--tun=userspace-networking \
|
--tun=userspace-networking \
|
||||||
>> "$STATE_DIR/tailscaled.log" 2>&1 &
|
>>"$STATE_DIR/tailscaled.log" 2>&1 &
|
||||||
TAILSCALED_PID=$!
|
TAILSCALED_PID=$!
|
||||||
|
|
||||||
# Wait for socket to appear (up to 15 seconds)
|
# Wait for socket to appear (up to 15 seconds)
|
||||||
i=0
|
i=0
|
||||||
while [ $i -lt 15 ] && [ ! -S "$STATE_DIR/tailscaled.sock" ]; do
|
while [ $i -lt 15 ] && [ ! -S "$STATE_DIR/tailscaled.sock" ]; do
|
||||||
sleep 1
|
sleep 1
|
||||||
i=$((i + 1))
|
i=$((i + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
logger -t "Tailscale_VPN" "Connecting to Tailscale network"
|
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).
|
# 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.
|
# Capture output so we can extract auth URL and log it to syslog for the web UI.
|
||||||
UP_OUT=$("$APP_DIR/lib/tailscale" \
|
UP_OUT=$("$APP_DIR/lib/tailscale" \
|
||||||
--socket="$STATE_DIR/tailscaled.sock" \
|
--socket="$STATE_DIR/tailscaled.sock" \
|
||||||
up --hostname="$(hostname)" --timeout=10s 2>&1) || true
|
up --hostname="$(hostname)" --timeout=10s 2>&1) || true
|
||||||
echo "$UP_OUT" >> "$STATE_DIR/tailscaled.log"
|
echo "$UP_OUT" >>"$STATE_DIR/tailscaled.log"
|
||||||
|
|
||||||
# If an auth URL was returned, log it so the web UI can show it
|
# 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)
|
AUTH_URL=$(echo "$UP_OUT" | grep -o 'https://login\.tailscale\.com/[^ ]*' | head -1)
|
||||||
@@ -48,18 +48,44 @@ 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
|
# 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_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)
|
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"
|
[ -n "$TS_VER" ] && logger -t "Tailscale_VPN" "Tailscale version: $TS_VER"
|
||||||
|
|
||||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
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" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
|
||||||
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
|
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
|
||||||
|
|
||||||
# Monitoring loop: stay alive while tailscaled is running.
|
# Publish tailscale's real backend state as JSON for the web UI to consume.
|
||||||
# This keeps the parent Tailscale_VPN (C launcher) in the process table
|
# This is the authoritative connection signal (BackendState / Self.Online /
|
||||||
# so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
|
# 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".
|
||||||
while kill -0 "$TAILSCALED_PID" 2>/dev/null; do
|
while kill -0 "$TAILSCALED_PID" 2>/dev/null; do
|
||||||
sleep 5
|
publish_status
|
||||||
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
|
rm -f "$STATUS_FILE" 2>/dev/null
|
||||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||||
|
|||||||
@@ -300,14 +300,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div id="status-banner" class="status-banner connecting">
|
<div id="status-banner" class="status-banner connecting">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span id="status-text" class="status-text">Checking...</span>
|
<span id="status-text" class="status-text">Checking...</span>
|
||||||
<span id="status-time" class="status-time"></span>
|
<span id="status-time" class="status-time"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Update available -->
|
|
||||||
<div id="update-banner" class="update-banner">
|
<div id="update-banner" class="update-banner">
|
||||||
<div class="update-text">Update available: <strong id="update-version"></strong></div>
|
<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">
|
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
||||||
@@ -316,7 +314,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auth (hidden by default) -->
|
|
||||||
<div id="auth-block" class="auth-block" style="display:none;">
|
<div id="auth-block" class="auth-block" style="display:none;">
|
||||||
<p>Authenticate this device to connect to your Tailscale network:</p>
|
<p>Authenticate this device to connect to your Tailscale network:</p>
|
||||||
<a id="auth-link" class="auth-btn" href="#" target="_blank">
|
<a id="auth-link" class="auth-btn" href="#" target="_blank">
|
||||||
@@ -326,7 +323,6 @@
|
|||||||
<span id="auth-url-text" class="auth-url"></span>
|
<span id="auth-url-text" class="auth-url"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connection Info -->
|
|
||||||
<div class="card" id="info-card" style="display:none;">
|
<div class="card" id="info-card" style="display:none;">
|
||||||
<div class="card-title">Connection Details</div>
|
<div class="card-title">Connection Details</div>
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
@@ -352,7 +348,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="log-controls">
|
<div class="log-controls">
|
||||||
<div class="card-title" style="margin-bottom:0;">Service Log</div>
|
<div class="card-title" style="margin-bottom:0;">Service Log</div>
|
||||||
@@ -436,11 +431,11 @@
|
|||||||
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary: extract hostname from Axis syslog header (always the real device hostname)
|
// The syslog header always carries the real device hostname.
|
||||||
var node = null;
|
var node = null;
|
||||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||||
if (hostLine) node = hostLine[1];
|
if (hostLine) node = hostLine[1];
|
||||||
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
|
// Fallback; may contain a stale acap-tailscale_vpn name.
|
||||||
if (!node) {
|
if (!node) {
|
||||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||||
@@ -493,7 +488,6 @@
|
|||||||
if (acap3ip) tsIP = acap3ip[1];
|
if (acap3ip) tsIP = acap3ip[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache when found, restore from cache when missing
|
|
||||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||||
tsIP = tsIP || cacheGet('ip');
|
tsIP = tsIP || cacheGet('ip');
|
||||||
node = node || cacheGet('node');
|
node = node || cacheGet('node');
|
||||||
@@ -609,9 +603,52 @@
|
|||||||
.catch(function() { return false; });
|
.catch(function() { return false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACAP3: also fetch the raw tailscaled.log (symlinked into html/) so the parser
|
// ACAP3: tailscaled's own log has the IP, version, tailnet and Running state.
|
||||||
// can find IP, version, tailnet and Running state from tailscaled's own output.
|
|
||||||
var DAEMON_LOG_URL = 'tailscaled.log';
|
var DAEMON_LOG_URL = 'tailscaled.log';
|
||||||
|
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) {
|
||||||
|
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') {
|
||||||
|
// Not online: transient drop or node removed/expired. Keep the log
|
||||||
|
// parser's URL 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() {
|
function refresh() {
|
||||||
var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||||
@@ -619,17 +656,22 @@
|
|||||||
var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||||
.then(function(r) { return r.text(); }).catch(function() { return ''; });
|
.then(function(r) { return r.text(); }).catch(function() { return ''; });
|
||||||
|
|
||||||
Promise.all([syslogFetch, daemonFetch]).then(function(res) {
|
Promise.all([syslogFetch, daemonFetch, fetchStatus()]).then(function(res) {
|
||||||
// Syslog provides Axis timestamp headers (node name) and start/stop events.
|
// Syslog provides Axis timestamp headers (node name) and start/stop events.
|
||||||
// tailscaled.log provides IP, version, tailnet, and -> Running state.
|
// tailscaled.log provides IP, version, tailnet, and -> Running state.
|
||||||
var txt = res[0] + '\n' + res[1];
|
var txt = res[0] + '\n' + res[1];
|
||||||
|
var st = res[2];
|
||||||
var result = parse(txt);
|
var result = parse(txt);
|
||||||
renderLogs(res[0]); // show syslog in log panel; daemon log is too verbose
|
renderLogs(res[0]); // daemon log is too verbose to show
|
||||||
// Always verify with the app status API - syslog can have stale entries
|
// Logs can have stale entries; confirm the app is running.
|
||||||
checkAppRunning().then(function(running) {
|
checkAppRunning().then(function(running) {
|
||||||
if (!running) {
|
if (!running) {
|
||||||
result.state = 'disconnected';
|
result.state = 'disconnected';
|
||||||
|
} else if (st && st.BackendState) {
|
||||||
|
// Authoritative: Tailscale's own backend state
|
||||||
|
applyStatus(result, st);
|
||||||
} else if (!result.url && result.state !== 'connected') {
|
} else if (!result.url && result.state !== 'connected') {
|
||||||
|
// Fallback to log heuristic when status.json is unavailable
|
||||||
result.state = 'connected';
|
result.state = 'connected';
|
||||||
result.ip = result.ip || cacheGet('ip');
|
result.ip = result.ip || cacheGet('ip');
|
||||||
result.node = result.node || cacheGet('node');
|
result.node = result.node || cacheGet('node');
|
||||||
|
|||||||
@@ -4,19 +4,9 @@
|
|||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ACAP 3 supervisor launcher for Tailscale_VPN.
|
* ACAP 3 launcher: elflibcheck needs APPNAME to be ELF, and acap-startstop,
|
||||||
*
|
* respawnd and list.cgi use pidof(APPNAME). So this stays resident, restarts
|
||||||
* elflibcheck.sh requires APPNAME to be an ELF binary.
|
* start.sh whenever it dies, and only exits on SIGTERM/SIGINT.
|
||||||
* acap-startstop / respawnd / list.cgi all use pidof(APPNAME) for status.
|
|
||||||
*
|
|
||||||
* This binary NEVER exits voluntarily — it loops restarting start.sh if it
|
|
||||||
* dies, so:
|
|
||||||
* - pidof Tailscale_VPN always finds this process → UI shows "Running"
|
|
||||||
* - respawnd never triggers (it only fires when APPNAME exits)
|
|
||||||
* - If tailscaled OOMs and start.sh exits, we cleanly restart it
|
|
||||||
*
|
|
||||||
* To stop the app, acap-startstop calls stop_daemon which sends SIGTERM here.
|
|
||||||
* We forward SIGTERM/SIGINT to the child and then exit.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
static volatile int g_stop = 0;
|
static volatile int g_stop = 0;
|
||||||
@@ -37,7 +27,6 @@ int main(void)
|
|||||||
while (!g_stop) {
|
while (!g_stop) {
|
||||||
pid_t pid = fork();
|
pid_t pid = fork();
|
||||||
if (pid == 0) {
|
if (pid == 0) {
|
||||||
/* child: reset signals and exec start.sh */
|
|
||||||
signal(SIGTERM, SIG_DFL);
|
signal(SIGTERM, SIG_DFL);
|
||||||
signal(SIGINT, SIG_DFL);
|
signal(SIGINT, SIG_DFL);
|
||||||
execl("/usr/local/packages/Tailscale_VPN/start.sh",
|
execl("/usr/local/packages/Tailscale_VPN/start.sh",
|
||||||
@@ -59,7 +48,6 @@ int main(void)
|
|||||||
g_child = -1;
|
g_child = -1;
|
||||||
|
|
||||||
if (!g_stop) {
|
if (!g_stop) {
|
||||||
/* start.sh died unexpectedly — wait before restarting */
|
|
||||||
sleep(3);
|
sleep(3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ PACKAGENAME=Tailscale_VPN
|
|||||||
MENUNAME="Tailscale VPN"
|
MENUNAME="Tailscale VPN"
|
||||||
VENDOR="Mo3he"
|
VENDOR="Mo3he"
|
||||||
APPMAJORVERSION=1
|
APPMAJORVERSION=1
|
||||||
APPMINORVERSION=96
|
APPMINORVERSION=102
|
||||||
APPMICROVERSION=4
|
APPMICROVERSION=4
|
||||||
APPTYPE=armv7hf
|
APPTYPE=armv7hf
|
||||||
APPNAME=Tailscale_VPN
|
APPNAME=Tailscale_VPN
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Build the Tailscale ACAP variants.
|
||||||
|
#
|
||||||
|
# ./build.sh # build every variant
|
||||||
|
# ./build.sh aarch64 arm # build only the named variant folders
|
||||||
|
#
|
||||||
|
# Variant folders map to release .eap suffixes: *_ROOT -> _root, *_acap3 -> _acap3.
|
||||||
|
# RUNTIME=docker|podman forces a container runtime; TAILSCALE_VERSION overrides
|
||||||
|
# the version resolved by ci/upstream-version.sh.
|
||||||
|
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
|
||||||
|
|
||||||
|
# Upstream ships unstripped binaries; stripping saves ~23 MB per package. Do it
|
||||||
|
# inside the SDK container: without host cross-binutils it silently did nothing.
|
||||||
|
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
|
||||||
Executable
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/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
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/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/
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/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
|
||||||
Executable
+171
@@ -0,0 +1,171 @@
|
|||||||
|
#!/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
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/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
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
PROG = Tailscale_VPN
|
PROG = Tailscale_VPN
|
||||||
SRCS = param_bridge.c
|
SRCS = param_bridge.c
|
||||||
PKGS = axparameter glib-2.0
|
PKGS = axparameter glib-2.0 gio-2.0
|
||||||
CFLAGS += $(shell pkg-config --cflags $(PKGS))
|
CFLAGS += $(shell pkg-config --cflags $(PKGS))
|
||||||
|
CFLAGS += $(EXTRA_CFLAGS)
|
||||||
LDADD = $(shell pkg-config --libs $(PKGS))
|
LDADD = $(shell pkg-config --libs $(PKGS))
|
||||||
|
|
||||||
all: $(PROG)
|
all: $(PROG)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# Arguments for `tailscale up`, built as a list so no setting is ever re-parsed
|
||||||
|
# by the shell.
|
||||||
|
set -- --socket="$SOCKET_PATH" up --reset --hostname="$(hostname)"
|
||||||
|
|
||||||
|
if [ -n "$CUSTOM_SERVER" ]; then
|
||||||
|
set -- "$@" --login-server "$CUSTOM_SERVER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$AUTH_KEY" ]; then
|
||||||
|
set -- "$@" --authkey "$AUTH_KEY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||||
|
set -- "$@" --accept-dns=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||||
|
set -- "$@" --accept-routes=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# "192.168.1.0/24, 10.0.0.0/24" is a natural way to type the list.
|
||||||
|
ADVERTISE_ROUTES=$(printf '%s' "$ADVERTISE_ROUTES" | tr -d ' \t\r\n')
|
||||||
|
|
||||||
|
# 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
|
||||||
|
set -- "$@" --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.
|
||||||
|
{
|
||||||
|
"$TAILSCALE_PATH" "$@"
|
||||||
|
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
|
||||||
@@ -272,6 +272,23 @@
|
|||||||
.save-status.ok { color: var(--green); }
|
.save-status.ok { color: var(--green); }
|
||||||
.save-status.err { color: var(--red); }
|
.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 indicator */
|
||||||
.refresh-bar {
|
.refresh-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -334,14 +351,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div id="status-banner" class="status-banner connecting">
|
<div id="status-banner" class="status-banner connecting">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span id="status-text" class="status-text">Checking...</span>
|
<span id="status-text" class="status-text">Checking...</span>
|
||||||
<span id="status-time" class="status-time"></span>
|
<span id="status-time" class="status-time"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Update available -->
|
|
||||||
<div id="update-banner" class="update-banner">
|
<div id="update-banner" class="update-banner">
|
||||||
<div class="update-text">Update available: <strong id="update-version"></strong></div>
|
<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">
|
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
||||||
@@ -350,7 +365,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auth (hidden by default) -->
|
|
||||||
<div id="auth-block" class="auth-block" style="display:none;">
|
<div id="auth-block" class="auth-block" style="display:none;">
|
||||||
<p>Authenticate this device to connect to your Tailscale network:</p>
|
<p>Authenticate this device to connect to your Tailscale network:</p>
|
||||||
<a id="auth-link" class="auth-btn" href="#" target="_blank">
|
<a id="auth-link" class="auth-btn" href="#" target="_blank">
|
||||||
@@ -360,7 +374,6 @@
|
|||||||
<span id="auth-url-text" class="auth-url"></span>
|
<span id="auth-url-text" class="auth-url"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connection Info -->
|
|
||||||
<div class="card" id="info-card" style="display:none;">
|
<div class="card" id="info-card" style="display:none;">
|
||||||
<div class="card-title">Connection Details</div>
|
<div class="card-title">Connection Details</div>
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
@@ -386,8 +399,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Proxy Info (always visible) -->
|
<!-- Hidden on ROOT builds, which have no local proxy -->
|
||||||
<div class="card">
|
<div class="card" id="proxy-info-card" style="display:none;">
|
||||||
<div class="card-title">Proxy Configuration</div>
|
<div class="card-title">Proxy Configuration</div>
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
@@ -401,30 +414,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">Settings</div>
|
<div class="card-title">Settings</div>
|
||||||
<div class="settings-form">
|
<div class="settings-form">
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<label class="settings-label" for="input-server">Custom Server URL</label>
|
<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)">
|
<input class="settings-input" id="input-server" type="text" autocomplete="off" placeholder="https://controlplane.example.com (leave blank for Tailscale)">
|
||||||
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
|
<span class="settings-hint">Leave blank to use official Tailscale servers.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<label class="settings-label" for="input-authkey">Auth Key</label>
|
<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)">
|
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||||
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
|
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row" id="http-port-row" style="display:none;">
|
||||||
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
|
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
|
||||||
<input class="settings-input" id="input-http-port" type="text" placeholder="8080">
|
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
|
||||||
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
|
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row" id="socks-port-row" style="display:none;">
|
||||||
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
|
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
|
||||||
<input class="settings-input" id="input-socks-port" type="text" placeholder="1080">
|
<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>
|
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
|
||||||
</div>
|
</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'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>
|
||||||
|
</div>
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
<span class="save-status" id="save-status"></span>
|
<span class="save-status" id="save-status"></span>
|
||||||
<button class="save-btn" id="save-btn">Save & Restart</button>
|
<button class="save-btn" id="save-btn">Save & Restart</button>
|
||||||
@@ -432,7 +469,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="log-controls">
|
<div class="log-controls">
|
||||||
<div class="card-title" style="margin-bottom:0;">Service Log</div>
|
<div class="card-title" style="margin-bottom:0;">Service Log</div>
|
||||||
@@ -453,6 +489,7 @@
|
|||||||
(function() {
|
(function() {
|
||||||
var APP = 'Tailscale_VPN';
|
var APP = 'Tailscale_VPN';
|
||||||
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
|
var LOG_URL = '/axis-cgi/admin/systemlog.cgi?appname=' + APP;
|
||||||
|
var STATUS_URL = 'status.json';
|
||||||
var logBox = document.getElementById('log-box');
|
var logBox = document.getElementById('log-box');
|
||||||
var autoScroll = true;
|
var autoScroll = true;
|
||||||
|
|
||||||
@@ -516,11 +553,11 @@
|
|||||||
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary: extract hostname from Axis syslog header (always the real device hostname)
|
// The syslog header always carries the real device hostname.
|
||||||
var node = null;
|
var node = null;
|
||||||
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
|
||||||
if (hostLine) node = hostLine[1];
|
if (hostLine) node = hostLine[1];
|
||||||
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
|
// Fallback; may contain a stale acap-tailscale_vpn name.
|
||||||
if (!node) {
|
if (!node) {
|
||||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||||
@@ -562,7 +599,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse proxy ports from log — use last match so old entries don't win
|
// Use the last match so older log entries don't win.
|
||||||
var httpPort = null;
|
var httpPort = null;
|
||||||
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
|
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]; }
|
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
|
||||||
@@ -570,7 +607,6 @@
|
|||||||
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
|
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]; }
|
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('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||||
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
|
||||||
tsIP = tsIP || cacheGet('ip');
|
tsIP = tsIP || cacheGet('ip');
|
||||||
@@ -584,9 +620,8 @@
|
|||||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||||
var isRunning = /-> Running/.test(lastState);
|
var isRunning = /-> Running/.test(lastState);
|
||||||
|
|
||||||
// Fallbacks only when syslog has rotated and no state transitions are visible.
|
// Only when syslog rotated away all state lines: our own "Tailscale VPN
|
||||||
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
|
// is running" line lingers in syslog and must not override them.
|
||||||
// "Tailscale VPN is running" message which stays in syslog indefinitely.
|
|
||||||
if (!isRunning && stateLines.length === 0) {
|
if (!isRunning && stateLines.length === 0) {
|
||||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||||
@@ -595,12 +630,11 @@
|
|||||||
/localapi:/.test(txt);
|
/localapi:/.test(txt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
// An auth URL after the last Running state means re-auth is needed
|
||||||
// (handles stale Running entries in syslog after reinstall or token expiry)
|
// (stale Running entries after reinstall or token expiry).
|
||||||
if (isRunning && latestUrl) {
|
if (isRunning && latestUrl) {
|
||||||
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
|
// Our shell log line is written after auth completes, so it post-dates
|
||||||
// (our shell log). The shell log is written AFTER auth completes, so it correctly
|
// the auth URL on success.
|
||||||
// post-dates the auth URL when connection succeeds.
|
|
||||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||||
@@ -664,7 +698,6 @@
|
|||||||
auth.style.display = 'none';
|
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.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.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
|
||||||
|
|
||||||
@@ -700,29 +733,78 @@
|
|||||||
.catch(function() { return false; });
|
.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) {
|
||||||
|
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') {
|
||||||
|
// Not online: transient drop or node removed/expired. Keep the log
|
||||||
|
// parser's URL; status.json's AuthURL lags during re-auth.
|
||||||
|
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() {
|
function refresh() {
|
||||||
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
Promise.all([
|
||||||
.then(function(r) { return r.text(); })
|
fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||||
.then(function(txt) {
|
.then(function(r) { return r.text(); })
|
||||||
var result = parse(txt);
|
.catch(function() { return ''; }),
|
||||||
renderLogs(txt);
|
fetchStatus()
|
||||||
// Always verify with the app status API - syslog can have stale entries
|
]).then(function(arr) {
|
||||||
checkAppRunning().then(function(running) {
|
var txt = arr[0];
|
||||||
if (!running) {
|
var st = arr[1];
|
||||||
result.state = 'disconnected';
|
var result = parse(txt || '');
|
||||||
} else if (!result.url && result.state !== 'connected') {
|
if (txt) renderLogs(txt);
|
||||||
result.state = 'connected';
|
// status.json goes stale when the app is stopped.
|
||||||
result.ip = result.ip || cacheGet('ip');
|
checkAppRunning().then(function(running) {
|
||||||
result.node = result.node || cacheGet('node');
|
if (!running) {
|
||||||
result.tailnet = result.tailnet || cacheGet('tailnet');
|
result.state = 'disconnected';
|
||||||
result.version = result.version || cacheGet('version');
|
} else if (st && st.BackendState) {
|
||||||
}
|
// Authoritative: Tailscale's own backend state
|
||||||
render(result);
|
applyStatus(result, st);
|
||||||
});
|
} else if (!result.url && result.state !== 'connected') {
|
||||||
})
|
// Fallback to log heuristic when status.json is unavailable
|
||||||
.catch(function() {
|
result.state = 'connected';
|
||||||
document.getElementById('status-text').textContent = 'Unable to fetch logs';
|
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);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
@@ -773,34 +855,83 @@
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings — load current param values and save on submit
|
// Settings
|
||||||
var PARAM_URL = '/axis-cgi/param.cgi';
|
var PARAM_URL = '/axis-cgi/param.cgi';
|
||||||
var serverInput = document.getElementById('input-server');
|
var serverInput = document.getElementById('input-server');
|
||||||
var authInput = document.getElementById('input-authkey');
|
var authInput = document.getElementById('input-authkey');
|
||||||
var httpPortInput = document.getElementById('input-http-port');
|
var httpPortInput = document.getElementById('input-http-port');
|
||||||
var socksPortInput= document.getElementById('input-socks-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 saveBtn = document.getElementById('save-btn');
|
||||||
var saveStatus = document.getElementById('save-status');
|
var saveStatus = document.getElementById('save-status');
|
||||||
|
|
||||||
|
// Fallback for devices without param.cgi (e.g. recorders).
|
||||||
|
var API_URL = '/local/' + APP + '/api/settings';
|
||||||
|
|
||||||
|
// False on ROOT builds (no local proxy). Also keeps the ports out of saves:
|
||||||
|
// param.cgi fails the whole update on a 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=(.*)/);
|
||||||
|
// No expected keys means this isn't param.cgi output (e.g. a 404 page).
|
||||||
|
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() {
|
function loadSettings() {
|
||||||
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
|
fetch(PARAM_URL + '?action=list&group=root.' + APP, { credentials: 'same-origin' })
|
||||||
.then(function(r) { return r.text(); })
|
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
|
||||||
.then(function(txt) {
|
.then(function(txt) { if (!applyParamText(txt)) return Promise.reject(); })
|
||||||
var sm = txt.match(/root\.\S+\.CustomServer=(.*)/);
|
.catch(function() { loadSettingsFallback(); });
|
||||||
var am = txt.match(/root\.\S+\.AuthKey=(.*)/);
|
}
|
||||||
var hm = txt.match(/root\.\S+\.HttpProxyPort=(.*)/);
|
|
||||||
var km = txt.match(/root\.\S+\.Socks5Port=(.*)/);
|
function loadSettingsFallback() {
|
||||||
if (sm) serverInput.value = sm[1].trim();
|
fetch(API_URL + '?t=' + Date.now(), { credentials: 'same-origin', cache: 'no-store' })
|
||||||
if (am) authInput.value = am[1].trim();
|
.then(function(r) { return r.ok ? r.json() : null; })
|
||||||
if (hm) httpPortInput.value = hm[1].trim();
|
.then(function(obj) { if (obj) applyJson(obj); })
|
||||||
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() {});
|
.catch(function() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,6 +941,31 @@
|
|||||||
if (msg) setTimeout(function() { saveStatus.textContent = ''; saveStatus.className = 'save-status'; }, 4000);
|
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 restarts its tunnel itself; no control.cgi call needed.
|
||||||
|
setStatus('Saved. Restarting...', 'ok');
|
||||||
|
} else {
|
||||||
|
setStatus('Error saving settings', 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.addEventListener('click', function() {
|
saveBtn.addEventListener('click', function() {
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
setStatus('Saving...', '');
|
setStatus('Saving...', '');
|
||||||
@@ -818,30 +974,32 @@
|
|||||||
var params = 'action=update' +
|
var params = 'action=update' +
|
||||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
(hasProxyPorts ? '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) : '') +
|
||||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort);
|
(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());
|
||||||
fetch(PARAM_URL, {
|
fetch(PARAM_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params
|
body: params
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.text(); })
|
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
|
||||||
.then(function(txt) {
|
.then(function(txt) {
|
||||||
saveBtn.disabled = false;
|
|
||||||
if (/^OK/.test(txt.trim())) {
|
if (/^OK/.test(txt.trim())) {
|
||||||
setStatus('Saved. Restarting...', 'ok');
|
setStatus('Saved. Restarting...', 'ok');
|
||||||
// Restart the app so new settings take effect
|
|
||||||
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
|
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
|
||||||
{ method: 'POST', credentials: 'same-origin' });
|
{ method: 'POST', credentials: 'same-origin' });
|
||||||
} else {
|
|
||||||
setStatus('Error: ' + txt.trim(), 'err');
|
|
||||||
}
|
}
|
||||||
|
setStatus('Error: ' + txt.trim(), 'err');
|
||||||
})
|
})
|
||||||
.catch(function(e) {
|
.catch(function() {
|
||||||
saveBtn.disabled = false;
|
// param.cgi unavailable (e.g. recorder-class device).
|
||||||
setStatus('Failed to save', 'err');
|
return saveViaFallback(httpPort, socksPort);
|
||||||
});
|
})
|
||||||
|
.then(function() { saveBtn.disabled = false; })
|
||||||
|
.catch(function() { saveBtn.disabled = false; setStatus('Failed to save', 'err'); });
|
||||||
});
|
});
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
// Copyright (C) 2024 Mo3he
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ACAP parameter bridge for Tailscale VPN: mirrors axparameter values into
|
||||||
|
* CONFIG_FILE, runs Tailscale_VPN_run as a child and restarts it on changes.
|
||||||
|
* Build with -DHAS_PROXY_PORTS for the userspace variants; ROOT has no 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In-place upgrades don't always register new manifest params (param.cgi then
|
||||||
|
* 404s); ax_parameter_add fails harmlessly if the param already exists. */
|
||||||
|
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;
|
||||||
|
/* Legacy AuthKey clear: the run script only exits 0 from its TERM/INT trap. */
|
||||||
|
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 `tailscale up` used the auth key.
|
||||||
|
* The exit-code-0 path in watchdog_cb rarely fires since the child stays up. */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The run script sources this file, so every value must be a single-quoted
|
||||||
|
* shell literal or it would be executed. */
|
||||||
|
static void write_var(FILE *f, const char *name, const char *value) {
|
||||||
|
fprintf(f, "%s='", name);
|
||||||
|
for (const char *p = value; *p; p++) {
|
||||||
|
if (*p == '\'')
|
||||||
|
fputs("'\\''", f);
|
||||||
|
else
|
||||||
|
fputc(*p, f);
|
||||||
|
}
|
||||||
|
fputs("'\n", f);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
write_var(f, "CUSTOM_SERVER", cache_get(&cfg_custom_server, ""));
|
||||||
|
write_var(f, "AUTH_KEY", cache_get(&cfg_auth_key, ""));
|
||||||
|
#ifdef HAS_PROXY_PORTS
|
||||||
|
write_var(f, "CONF_HTTP", cache_get(&cfg_http_proxy_port, "8080"));
|
||||||
|
write_var(f, "CONF_SOCKS", cache_get(&cfg_socks5_port, "1080"));
|
||||||
|
#endif
|
||||||
|
write_var(f, "ACCEPT_DNS", cache_get(&cfg_accept_dns, "false"));
|
||||||
|
write_var(f, "ACCEPT_ROUTES", cache_get(&cfg_accept_routes, "false"));
|
||||||
|
write_var(f, "ADVERTISE_ROUTES", 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) ──────────────────
|
||||||
|
* For devices without /axis-cgi/param.cgi (e.g. recorders); reached via the
|
||||||
|
* manifest reverseProxy at /local/Tailscale_VPN/api/settings. */
|
||||||
|
|
||||||
|
#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, '}');
|
||||||
|
/* Not g_string_free(out, FALSE): glib >= 2.76 headers turn it into
|
||||||
|
* g_string_free_and_steal(), missing from older runtimes (AXIS OS 11.x). */
|
||||||
|
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");
|
||||||
|
|
||||||
|
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||||
|
|
||||||
|
/* A stale sentinel would clear a freshly configured key before use. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
+53
-47
@@ -3,22 +3,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Tailscale VPN for Axis Cameras</title>
|
<title>Tailscale VPN for Axis Devices</title>
|
||||||
<meta name="description" content="Install Tailscale VPN directly on your Axis camera. Secure remote access with WireGuard, no extra hardware needed.">
|
<meta name="description" content="Install Tailscale VPN directly on your Axis device. Secure remote access with WireGuard, no extra hardware needed.">
|
||||||
|
|
||||||
<!-- Open Graph / LinkedIn -->
|
<!-- Open Graph / LinkedIn -->
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://mo3he.github.io/Axis_Cam_Tailscale/">
|
<meta property="og:url" content="https://mo3he.github.io/Axis_Cam_Tailscale/">
|
||||||
<meta property="og:title" content="Tailscale VPN for Axis Cameras">
|
<meta property="og:title" content="Tailscale VPN for Axis Devices">
|
||||||
<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:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
|
||||||
<meta property="og:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
|
<meta property="og:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
|
||||||
<meta property="og:image:width" content="1340">
|
<meta property="og:image:width" content="1340">
|
||||||
<meta property="og:image:height" content="724">
|
<meta property="og:image:height" content="724">
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Tailscale VPN for Axis Cameras">
|
<meta name="twitter:title" content="Tailscale VPN for Axis Devices">
|
||||||
<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:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
|
||||||
<meta name="twitter:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
|
<meta name="twitter:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -117,6 +117,11 @@
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
.hero h1 span { color: var(--accent); }
|
.hero h1 span { color: var(--accent); }
|
||||||
|
.hero-word {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--accent);
|
||||||
|
transition: opacity 0.25s, transform 0.25s;
|
||||||
|
}
|
||||||
.hero p {
|
.hero p {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -223,7 +228,7 @@
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
.tag-recommended { background: rgba(52, 211, 153, 0.15); color: var(--green); }
|
.tag-recommended { background: rgba(52, 211, 153, 0.15); color: var(--green); }
|
||||||
.tag-custom { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
|
.tag-acap3 { background: rgba(168, 85, 247, 0.15); color: #a855f7; }
|
||||||
.tag-root { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
|
.tag-root { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
|
||||||
.download-card h3 {
|
.download-card h3 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@@ -334,7 +339,6 @@
|
|||||||
<a href="#install">Install</a>
|
<a href="#install">Install</a>
|
||||||
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
|
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
|
||||||
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener" style="color: #db61a2;">Sponsor</a>
|
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener" style="color: #db61a2;">Sponsor</a>
|
||||||
<a href="https://buymeacoffee.com/mo3he" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:0.35rem;background:#FFDD00;color:#000;font-weight:700;font-size:0.85rem;padding:0.35rem 0.85rem;border-radius:8px;line-height:1;">☕ Buy me a coffee</a>
|
|
||||||
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">
|
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">
|
||||||
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
<svg id="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>
|
<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>
|
||||||
@@ -342,11 +346,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Hero -->
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="badge">Open Source · ACAP Package</div>
|
<div class="badge">Open Source · ACAP Package</div>
|
||||||
<h1>Tailscale VPN for <span>Axis Cameras</span></h1>
|
<h1>Tailscale VPN for<br>Axis <span class="hero-word" id="heroWord">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>
|
<p>Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.</p>
|
||||||
<div class="hero-buttons">
|
<div class="hero-buttons">
|
||||||
<a href="#downloads" class="btn btn-primary">
|
<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>
|
<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>
|
||||||
@@ -359,7 +362,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Disclaimer -->
|
|
||||||
<div style="max-width:720px;margin:0 auto;padding:0 1.5rem 1.5rem;">
|
<div style="max-width:720px;margin:0 auto;padding:0 1.5rem 1.5rem;">
|
||||||
<div style="background:rgba(79,143,247,0.07);border:1px solid rgba(79,143,247,0.18);border-radius:10px;padding:12px 18px;font-size:12.5px;color:var(--muted);line-height:1.6;">
|
<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>
|
<strong style="color:var(--text);">Disclaimer:</strong>
|
||||||
@@ -369,7 +371,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
|
||||||
<section id="features" class="features">
|
<section id="features" class="features">
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="icon">🔒</div>
|
<div class="icon">🔒</div>
|
||||||
@@ -379,12 +380,12 @@
|
|||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="icon">⚡</div>
|
<div class="icon">⚡</div>
|
||||||
<h3>No Root Required</h3>
|
<h3>No Root Required</h3>
|
||||||
<p>Runs in user-space networking mode on Axis OS 12+. No need to enable root access on your camera.</p>
|
<p>Runs in user-space networking mode on Axis OS 12+. No need to enable root access on your device.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="icon">📦</div>
|
<div class="icon">📦</div>
|
||||||
<h3>Simple EAP Install</h3>
|
<h3>Simple EAP Install</h3>
|
||||||
<p>Upload the .eap file through your camera's web interface. Start the app and authenticate - done.</p>
|
<p>Upload the .eap file through your device's web interface. Start the app and authenticate - done.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="icon">🔄</div>
|
<div class="icon">🔄</div>
|
||||||
@@ -394,21 +395,19 @@
|
|||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="icon">🌐</div>
|
<div class="icon">🌐</div>
|
||||||
<h3>Headscale Compatible</h3>
|
<h3>Headscale Compatible</h3>
|
||||||
<p>The Custom variant supports self-hosted Headscale servers with configurable server URL and auth key.</p>
|
<p>Supports self-hosted Headscale servers with configurable server URL and auth key, built into every variant.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="icon">🏗️</div>
|
<div class="icon">🔀</div>
|
||||||
<h3>ARM & AARCH64</h3>
|
<h3>Outbound Proxy</h3>
|
||||||
<p>Supports both ARM (armv7hf) and AARCH64 architectures, covering a wide range of Axis camera models.</p>
|
<p>Allows the device to route its own outbound traffic through Tailscale via a local HTTP/HTTPS and SOCKS5 proxy.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Downloads -->
|
|
||||||
<section id="downloads" class="downloads">
|
<section id="downloads" class="downloads">
|
||||||
<h2>Download</h2>
|
<h2>Download</h2>
|
||||||
<p class="subtitle">Pick the right variant for your camera and Axis OS version.</p>
|
<p class="subtitle">Pick the right variant for your device and Axis OS version.</p>
|
||||||
<div class="download-grid">
|
<div class="download-grid">
|
||||||
<!-- Standard -->
|
|
||||||
<div class="download-card">
|
<div class="download-card">
|
||||||
<div class="tag tag-recommended">Recommended</div>
|
<div class="tag tag-recommended">Recommended</div>
|
||||||
<h3>Standard (Non-Root)</h3>
|
<h3>Standard (Non-Root)</h3>
|
||||||
@@ -424,23 +423,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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="download-card">
|
||||||
<div class="tag tag-root">Root · Legacy</div>
|
<div class="tag tag-root">Root · Legacy</div>
|
||||||
<h3>Root (Full Networking)</h3>
|
<h3>Root (Full Networking)</h3>
|
||||||
@@ -456,10 +438,20 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="download-card">
|
||||||
|
<div class="tag tag-acap3">ACAP3 · 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Install -->
|
|
||||||
<section id="install" class="install">
|
<section id="install" class="install">
|
||||||
<h2>Installation</h2>
|
<h2>Installation</h2>
|
||||||
<div class="steps">
|
<div class="steps">
|
||||||
@@ -467,14 +459,14 @@
|
|||||||
<div class="step-num">1</div>
|
<div class="step-num">1</div>
|
||||||
<div class="step-text">
|
<div class="step-text">
|
||||||
<strong>Download the EAP</strong>
|
<strong>Download the EAP</strong>
|
||||||
<span>Grab the right .eap file for your camera architecture from the releases page above.</span>
|
<span>Grab the right .eap file for your device architecture from the releases page above.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step-num">2</div>
|
<div class="step-num">2</div>
|
||||||
<div class="step-text">
|
<div class="step-text">
|
||||||
<strong>Upload to your camera</strong>
|
<strong>Upload to your device</strong>
|
||||||
<span>Log into your Axis camera web interface and go to <strong>Apps → Add App</strong>. Upload the .eap file.</span>
|
<span>Log into your Axis device web interface and go to <strong>Apps → Add App</strong>. Upload the .eap file.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
@@ -487,7 +479,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
|
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
|
||||||
@@ -540,6 +531,21 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
// Rewrite download links to point directly to latest release assets
|
||||||
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
|
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
@@ -555,9 +561,9 @@
|
|||||||
var archMatch = name.indexOf(arch) !== -1;
|
var archMatch = name.indexOf(arch) !== -1;
|
||||||
var typeMatch = false;
|
var typeMatch = false;
|
||||||
if (type === 'Tailscale_VPN') {
|
if (type === 'Tailscale_VPN') {
|
||||||
typeMatch = name.indexOf('custom') === -1 && name.indexOf('root') === -1;
|
typeMatch = name.indexOf('root') === -1 && name.indexOf('acap3') === -1;
|
||||||
} else if (type === 'custom') {
|
} else if (type === 'acap3') {
|
||||||
typeMatch = name.indexOf('custom') !== -1;
|
typeMatch = name.indexOf('acap3') !== -1;
|
||||||
} else if (type === 'ROOT') {
|
} else if (type === 'ROOT') {
|
||||||
typeMatch = name.indexOf('root') !== -1;
|
typeMatch = name.indexOf('root') !== -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"dependencyDashboard": true,
|
||||||
|
"prConcurrentLimit": 3,
|
||||||
|
"prHourlyLimit": 0,
|
||||||
|
"schedule": ["before 5am on Monday"],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "An SDK bump raises the package's minimum AXIS OS, so decide it deliberately.",
|
||||||
|
"matchDatasources": ["docker"],
|
||||||
|
"matchPackageNames": [
|
||||||
|
"axisecp/acap-native-sdk",
|
||||||
|
"axisecp/acap-sdk",
|
||||||
|
"docker.io/axisecp/acap-native-sdk",
|
||||||
|
"docker.io/axisecp/acap-sdk"
|
||||||
|
],
|
||||||
|
"dependencyDashboardApproval": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Transitive Go modules are off by default; enable so CVE fixes can land.",
|
||||||
|
"matchManagers": ["gomod"],
|
||||||
|
"matchDepTypes": ["indirect"],
|
||||||
|
"enabled": true,
|
||||||
|
"groupName": "Go indirect dependencies",
|
||||||
|
"groupSlug": "go-indirect-dependencies"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user