mirror of
https://github.com/Mo3he/Axis_Cam_Tailscale.git
synced 2026-10-01 12:05:37 +00:00
Compare commits
| 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 | ||
|
|
41e2a508c0 | ||
|
|
256aaa9d23 | ||
|
|
3cc3cd4804 | ||
|
|
2c7ce1dc52 | ||
|
|
6bacc9e92b | ||
|
|
281fed0aa0 | ||
|
|
988adfc83a | ||
|
|
0761b6e959 | ||
|
|
ab2f4c050e | ||
|
|
c396f00d0b | ||
|
|
ced340453c | ||
|
|
4afd79167e | ||
|
|
e9919e069d | ||
|
|
732dacc5ba | ||
|
|
27e26c65e3 | ||
|
|
8a070bf5bf | ||
|
|
de58375eb3 | ||
|
|
86c6ecb385 | ||
|
|
7bb541fa2a | ||
|
|
3e87803625 | ||
|
|
8bd18ffc63 | ||
|
|
c2b15fb7ec | ||
|
|
b07e4f1f61 | ||
|
|
75f9cfd4b7 | ||
|
|
96e8a8fd97 | ||
|
|
8c9072b876 | ||
|
|
cf84c3cdb6 | ||
|
|
db05725432 | ||
|
|
ef7d73ca0f | ||
|
|
5080ec39fb | ||
|
|
219ff70c84 | ||
|
|
7a7ecec47c | ||
|
|
3ab196b7b5 | ||
|
|
6be4d7d8ad | ||
|
|
64ee3e49ab | ||
|
|
4df48e8ae6 | ||
|
|
806c3309b6 | ||
|
|
a4deaeacae | ||
|
|
6ba1c1a1a1 | ||
|
|
25df94e489 | ||
|
|
a72987391c | ||
|
|
04907d55bf | ||
|
|
54a1859f14 | ||
|
|
81e38c28b6 | ||
|
|
8643e99833 | ||
|
|
95e4a9aec2 | ||
|
|
fb67b6dff3 | ||
|
|
77fc186d0f | ||
|
|
b789d2e78e | ||
|
|
937b8ede3e | ||
|
|
49898008f6 | ||
|
|
5b09a40231 | ||
|
|
98918ca6f5 | ||
|
|
08b0fdc081 | ||
|
|
f3c6dd54f3 | ||
|
|
c3cbf8d433 | ||
|
|
39a5a42def | ||
|
|
713550d1c0 | ||
|
|
14db783425 | ||
|
|
c02f0aa498 | ||
|
|
96477cdb0d | ||
|
|
4e0eabeec3 | ||
|
|
59161ccd91 | ||
|
|
e1b2ac3490 | ||
|
|
5f03ac918f | ||
|
|
2db9f27181 | ||
|
|
396b450415 | ||
|
|
2d1ff5e8a6 | ||
|
|
7655028477 | ||
|
|
f890d8df1a | ||
|
|
df0756a64d | ||
|
|
df2cf84c6c | ||
|
|
f420e7d24c | ||
|
|
3dc49f1254 | ||
|
|
2005ba32db | ||
|
|
1f9c6a9e45 | ||
|
|
6148fc3298 | ||
|
|
bd60c24b5b | ||
|
|
7926c58b3d | ||
|
|
6bdca7eadd | ||
|
|
8b941b08a5 | ||
|
|
d157a91bae | ||
|
|
b35ed437b5 | ||
|
|
cb390384a1 | ||
|
|
533fc53040 | ||
|
|
0a85c286b4 | ||
|
|
d7e7b63952 | ||
|
|
ed7643b2eb | ||
|
|
c4b24aee40 | ||
|
|
bec473f0a8 | ||
|
|
5278677098 | ||
|
|
c21f640622 | ||
|
|
83e108bb05 | ||
|
|
38d7af6c86 | ||
|
|
51ead4a708 | ||
|
|
123906c98b | ||
|
|
f6999311b7 | ||
|
|
a95415d687 | ||
|
|
7d584d1a22 | ||
|
|
e27f0cdb34 | ||
|
|
e5954eac52 | ||
|
|
91a45be400 | ||
|
|
9b06b49fef | ||
|
|
811aa9e2f5 | ||
|
|
f69a1971a2 | ||
|
|
e01a2ab95f | ||
|
|
5b87555295 | ||
|
|
4a4618344a | ||
|
|
de739dec13 | ||
|
|
ef0cb156be | ||
|
|
f3556d3e76 | ||
|
|
d5e2be837f | ||
|
|
10f5cec1dd | ||
|
|
eb46f0f201 | ||
|
|
5a6aada216 | ||
|
|
65509f037e | ||
|
|
5e27f2f6ca | ||
|
|
f27769f415 | ||
|
|
b8038797f3 | ||
|
|
07f73648bf | ||
|
|
108ea203b4 | ||
|
|
4b282e81e6 | ||
|
|
34943f62b9 | ||
|
|
3d12e28a1f | ||
|
|
b453bcb6f2 | ||
|
|
616d270350 | ||
|
|
17c2ae705c | ||
|
|
54be4dd84f | ||
|
|
452f7d54d8 | ||
|
|
b444c964f6 | ||
|
|
e0178b531e | ||
|
|
46f7a524c0 | ||
|
|
f5ab42fd3f | ||
|
|
019beb3c73 | ||
|
|
db343b4447 | ||
|
|
f3163cae45 | ||
|
|
b7391743d6 | ||
|
|
2b373227d2 | ||
|
|
fa4e6edde1 | ||
|
|
c26643adf0 | ||
|
|
39bc29e1ab | ||
|
|
af3813135a | ||
|
|
5fe5a01f59 | ||
|
|
080b3bb90e | ||
|
|
41b9bd04b5 | ||
|
|
86d902d289 |
+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
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
# 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:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build. Empty resolves from upstream."
|
||||
required: false
|
||||
force:
|
||||
description: "Rebuild and re-cut the draft even if unchanged."
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: acap-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build: ${{ steps.decide.outputs.build }}
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
upstream: ${{ steps.decide.outputs.upstream }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_FORCE: ${{ github.event.inputs.force }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: ./ci/resolve-version.sh
|
||||
|
||||
build:
|
||||
needs: check
|
||||
if: needs.check.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.check.outputs.version }}
|
||||
UPSTREAM_VERSION: ${{ needs.check.outputs.upstream }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Apply version and upstream pins
|
||||
run: ./ci/apply-version.sh "$VERSION" "$UPSTREAM_VERSION"
|
||||
|
||||
# Repos without a tests/run.sh simply skip this.
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -x tests/run.sh ] || [ -f tests/run.sh ]; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends pkg-config libglib2.0-dev
|
||||
sh tests/run.sh
|
||||
else
|
||||
echo "no tests/run.sh, skipping"
|
||||
fi
|
||||
|
||||
- name: Build packages
|
||||
run: ./ci/build-packages.sh
|
||||
|
||||
- name: Verify packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
packages=(releases/*.eap)
|
||||
if [ ${#packages[@]} -eq 0 ]; then
|
||||
echo "no .eap produced" >&2
|
||||
exit 1
|
||||
fi
|
||||
for package in "${packages[@]}"; do
|
||||
echo "== $package"
|
||||
tar tzf "$package" >/dev/null
|
||||
done
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: releases/*.eap
|
||||
if-no-files-found: error
|
||||
|
||||
# Unstripped binaries for symbolising a crash from a shipped (stripped)
|
||||
# package. Not a release asset: they are only useful while debugging.
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: debug-symbols
|
||||
path: debug/
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Only after a successful build, so a failed upstream jump leaves main clean.
|
||||
- name: Commit version bump
|
||||
if: needs.check.outputs.release == 'true' && github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
# Packages are already uploaded as an artifact; removing them here
|
||||
# keeps build output out of the commit regardless of .gitignore.
|
||||
rm -rf releases
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "nothing to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update to $VERSION"
|
||||
# The remote can move while a long build runs, so rebase and retry.
|
||||
for attempt in 1 2 3; do
|
||||
if git push; then
|
||||
exit 0
|
||||
fi
|
||||
echo "push rejected, rebasing (attempt $attempt)"
|
||||
git pull --rebase --autostash origin main
|
||||
done
|
||||
echo "could not push the version bump" >&2
|
||||
exit 1
|
||||
|
||||
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 }}
|
||||
+16
@@ -1,2 +1,18 @@
|
||||
**/.DS_Store
|
||||
**/build
|
||||
|
||||
# Do not track release artifacts (local .eap build outputs stay untracked anywhere in the tree)
|
||||
releases/
|
||||
build/
|
||||
*.eap
|
||||
|
||||
# Unstripped binaries kept for crash symbolisation, uploaded as a CI artifact
|
||||
debug/
|
||||
|
||||
# Do not track downloaded Tailscale tarballs and temp bins
|
||||
tailscale_bins/
|
||||
*.tgz
|
||||
|
||||
# Tailscale binaries - downloaded fresh by CI at build time, not stored in git
|
||||
*/app/lib/tailscale
|
||||
*/app/lib/tailscaled
|
||||
|
||||
@@ -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 want to contribute](#i-want-to-contribute)
|
||||
- [Reporting bugs](#reporting-bugs)
|
||||
- [Before submitting a bug report](#before-submitting-a-bug-report)
|
||||
- [How do I submit a good bug report?](#how-do-i-submit-a-good-bug-report)
|
||||
- [Suggesting enhancements](#suggesting-enhancements)
|
||||
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
|
||||
- [How do I submit a good enhancement suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
|
||||
- [Your first code contribution](#your-first-code-contribution)
|
||||
- [Reporting bugs](#reporting-bugs)
|
||||
- [Before submitting a bug report](#before-submitting-a-bug-report)
|
||||
- [How do I submit a good bug report?](#how-do-i-submit-a-good-bug-report)
|
||||
- [Suggesting enhancements](#suggesting-enhancements)
|
||||
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
|
||||
- [How do I submit a good enhancement suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
|
||||
- [Your first code contribution](#your-first-code-contribution)
|
||||
|
||||
## I have a question
|
||||
|
||||
@@ -46,13 +46,13 @@ A good bug report shouldn't leave others needing to chase you up for more inform
|
||||
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker][issues_bugs].
|
||||
- Also make sure to search the internet to see if users outside of the GitHub community have discussed the issue.
|
||||
- Collect information about the bug:
|
||||
- Axis device model
|
||||
- Axis device firmware version
|
||||
- Stack trace
|
||||
- OS and version (Windows, Linux, macOS, x86, ARM)
|
||||
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant
|
||||
- Possibly your input and the output
|
||||
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
|
||||
- Axis device model
|
||||
- Axis device firmware version
|
||||
- Stack trace
|
||||
- OS and version (Windows, Linux, macOS, x86, ARM)
|
||||
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant
|
||||
- Possibly your input and the output
|
||||
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
|
||||
|
||||
#### How do I submit a good bug report?
|
||||
|
||||
@@ -126,9 +126,9 @@ Before opening a Pull Request (PR), please consider the following guidelines:
|
||||
And finally when you are satisfied with your changes, open a new PR.
|
||||
|
||||
<!-- markdownlint-disable MD034 -->
|
||||
[issues]: https://github.com/AxisCommunications/tailscale-acap/issues
|
||||
[issues_new]: https://github.com/AxisCommunications/tailscale-acap/issues/new
|
||||
[issues_bugs]: https://github.com/AxisCommunications/tailscale-acap/issues?q=label%3Abug
|
||||
[discussions]: https://github.com/AxisCommunications/tailscale-acap/discussions
|
||||
[discussions_new]: https://github.com/AxisCommunications/tailscale-acap/discussions/new
|
||||
[issues]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues
|
||||
[issues_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues/new
|
||||
[issues_bugs]: https://github.com/Mo3he/Axis_Cam_Tailscale/issues?q=label%3Abug
|
||||
[discussions]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions
|
||||
[discussions_new]: https://github.com/Mo3he/Axis_Cam_Tailscale/discussions/new
|
||||
<!-- markdownlint-enable MD034 -->
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
All rights reserved.
|
||||
Copyright (c) 2022, Weston Blieden
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
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
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
@@ -1,80 +1,255 @@
|
||||
# The Tailscale installer ACAP
|
||||
# Tailscale ACAP for Axis Cameras
|
||||
|
||||
This ACAP packages the scripts and files required to install the Tailscale VPN client on Axis Cameras.
|
||||
[](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)
|
||||
|
||||
Current version 1.52.0
|
||||
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.
|
||||
|
||||
https://tailscale.com/changelog/
|
||||
**[Visit the Homepage](https://mo3he.github.io/Axis_Cam_Tailscale/)**
|
||||
|
||||
## Purpose
|
||||
> **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.
|
||||
|
||||
Adding a VPN client directly to the camera allows secure remote access to the device without requiring any other equipment or network configuration.
|
||||
Tailscale achieves this in a secure, simple to setup and easy to use way.
|
||||
Tailscale is based on WireGuard VPN tunneling technology.
|
||||
> **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).
|
||||
|
||||
https://tailscale.com/blog/how-tailscale-works/
|
||||
## Table of Contents
|
||||
|
||||
## Links
|
||||
- [Overview](#overview)
|
||||
- [Compatibility](#compatibility)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Ports & security](#ports--security)
|
||||
- [Accessing Tailnet services from the camera](#accessing-tailnet-services-from-the-camera)
|
||||
- [Updating Tailscale](#updating-tailscale)
|
||||
- [Build from source](#build-from-source)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Links](#links)
|
||||
- [License](#license)
|
||||
|
||||
https://tailscale.com/
|
||||
## Overview
|
||||
|
||||
https://github.com/tailscale/tailscale
|
||||
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.
|
||||
|
||||
https://www.wireguard.com/
|
||||
- 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.
|
||||
|
||||
https://www.axis.com/
|
||||
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
|
||||
|
||||
The Tailscale ACAP is compatable with Axis cameras with arm and aarch64 based Soc's.
|
||||
| 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 |
|
||||
|
||||
```
|
||||
curl --anyauth "*" -u <username>:<password> <device ip>/axis-cgi/basicdeviceinfo.cgi --data "{\"apiVersion\":\"1.0\",\"context\":\"Client defined request ID\",\"method\":\"getAllProperties\"}"
|
||||
> Most cameras use the standard **ACAP 4** build. The **root** builds add
|
||||
> kernel-level networking but only run on AXIS OS 10.12–11.x (AXIS OS 12+ removed
|
||||
> root access for ACAPs). Use the **ACAP 3** build only on legacy cameras that
|
||||
> don't support ACAP 4 (AXIS OS 9–10).
|
||||
|
||||
**Verified on AXIS OS 13** (13.0.0, aarch64).
|
||||
|
||||
## Installation
|
||||
|
||||
> **Signed packages:** Release `.eap` files are signed with the Axis ACAP
|
||||
> signing service and install normally on AXIS OS 12.10 and later.
|
||||
>
|
||||
> **Upgrading from an earlier version?** The signing vendor changed, so
|
||||
> installing over a previously installed unsigned build can fail with
|
||||
> **"Couldn't install: app"** (device log: *"Vendor ID in manifest does not
|
||||
> match the vendor ID of the previous version"*). To upgrade: back up your app
|
||||
> configuration, **uninstall** the old version, then install the signed one.
|
||||
|
||||
Get the **prebuilt `.eap` file** from the
|
||||
[Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
|
||||
|
||||
1. Log into your Axis camera.
|
||||
2. Go to **Apps -> Add App**.
|
||||
3. Upload the `.eap` file.
|
||||
|
||||
Once installed:
|
||||
|
||||
- Start the app.
|
||||
- Click **Open** to view logs and get your Tailscale authentication URL.
|
||||
- On uninstall, all changes/files are removed.
|
||||
|
||||
> You'll need a [Tailscale account](https://tailscale.com/) to authenticate.
|
||||
|
||||
## Configuration
|
||||
|
||||
The app runs a C-based parameter bridge that reads settings from the ACAP
|
||||
parameter store and launches Tailscale. View logs and connection status via the
|
||||
**Open** button in the app, and authenticate using the provided URL or by
|
||||
pre-entering an auth key in **Settings**. Parameter changes (ports, server URL,
|
||||
auth key) are applied automatically without reinstalling the app.
|
||||
|
||||
All parameters are configurable via the web UI (**Open -> Settings** card) and
|
||||
take effect immediately:
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| Custom Server URL | *(empty)* | Control server URL for [Headscale](https://headscale.net/) or other self-hosted servers. Leave blank to use Tailscale's official servers. |
|
||||
| Auth Key | *(empty)* | Pre-authentication key (`tskey-auth-...`). Cleared automatically after first successful connection. Leave blank to authenticate via browser. |
|
||||
| HTTP Proxy Port | `8080` | Port for the outbound HTTP/HTTPS proxy. |
|
||||
| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 proxy. |
|
||||
| Accept DNS | `off` | Passes `--accept-dns=true` to `tailscale up`. Allows the tailnet to push DNS settings to the camera. Not available on `armv7hf_acap3`. |
|
||||
| Accept Routes | `off` | Passes `--accept-routes=true` to `tailscale up`. Allows the camera to use subnet routes advertised by other nodes. Not available on `armv7hf_acap3`. |
|
||||
| Advertise Routes (Subnet Router) | *(empty)* | Comma-separated CIDRs (e.g. `192.168.1.0/24,10.0.0.0/8`) this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving. Leave blank to disable. |
|
||||
|
||||
## Ports & security
|
||||
|
||||
All non-ROOT variants expose two local proxy endpoints that route outbound
|
||||
traffic through the Tailscale tunnel. The ports are configurable via **Settings
|
||||
-> HTTP Proxy Port / SOCKS5 Proxy Port**.
|
||||
|
||||
| Proxy | Default address | Routes |
|
||||
|---|---|---|
|
||||
| HTTP CONNECT | `http://127.0.0.1:8080` | HTTP and HTTPS traffic |
|
||||
| SOCKS5 | `127.0.0.1:1080` | Any SOCKS5-aware app or service |
|
||||
|
||||
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>`.
|
||||
|
||||
> **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.
|
||||
|
||||
## Accessing Tailnet services from the camera
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
where `<device ip>` is the IP address of the Axis device, `<username>` is the root username and `<password>` is the root password. Please
|
||||
note that you need to enclose your password with quotes (`'`) if it contains special characters.
|
||||
Then, in **System -> Storage -> Add network share**, use `127.0.0.1` as the share
|
||||
host and connect.
|
||||
|
||||
## Installing
|
||||
## Updating Tailscale
|
||||
|
||||
The recommended way to install this ACAP is to use the pre built eap file.
|
||||
Go to "Apps" on the camera and click "Add app".
|
||||
- New `.eap` files are auto-built and released **weekly** (if a new Tailscale
|
||||
version is available).
|
||||
- To update, simply install the new `.eap` over the existing one.
|
||||
|
||||
### Manual update (advanced)
|
||||
|
||||
## Using the Tailscale ACAP
|
||||
Replace the binaries in `common/app/lib/` (shared by `aarch64`, `armv7hf`, and
|
||||
their ROOT variants) or `arm_acap3/app/lib/` (legacy variant, kept separate):
|
||||
|
||||
The Tailscale ACAP will run a script on startup that sets the required permissions and starts the service and app.
|
||||
Once started click "Open" to see the output of the logs for further instructions and obtain the authetication URL.
|
||||
- `tailscale`
|
||||
- `tailscaled`
|
||||
|
||||
When uninstalling the ACAP, all changes and files are removed from the camera.
|
||||
Download the latest versions:
|
||||
[Tailscale static builds](https://pkgs.tailscale.com/stable/#static).
|
||||
|
||||
You will need a tailscale.com account to use the ACAP
|
||||
## Build from source
|
||||
|
||||
## Updating Tailscale version
|
||||
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.
|
||||
|
||||
The eap files will be updated from time to time and simply installing the new version over the old will update all files.
|
||||
All variants build from the **repository root**, pointing at the variant's own
|
||||
`Dockerfile`:
|
||||
|
||||
It's also possible to build and use a locally built image as all necesary files are provided.
|
||||
|
||||
Replace binaries "tailscale" and "tailscaled" in lib folder with new versions.
|
||||
Make sure you use the files for the correct Soc.
|
||||
|
||||
Latest versions can be found at
|
||||
|
||||
https://pkgs.tailscale.com/stable/#static
|
||||
|
||||
|
||||
To build,
|
||||
From main directory of the version you want (arm/aarch64)
|
||||
|
||||
```
|
||||
docker build --tag <package name> .
|
||||
```
|
||||
```
|
||||
docker cp $(docker create <package name>):/opt/app ./build
|
||||
```bash
|
||||
docker build -f aarch64/Dockerfile --tag <package_name> .
|
||||
docker cp $(docker create <package_name>):/opt/app ./build
|
||||
```
|
||||
|
||||
(Same for the others: just swap in `arm/Dockerfile`, `aarch64_ROOT/Dockerfile`,
|
||||
`arm_ROOT/Dockerfile`, or `arm_acap3/Dockerfile`.)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### AXIS OS 13 Preparation
|
||||
|
||||
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.
|
||||
|
||||
- [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.
|
||||
|
||||
### General Improvements
|
||||
|
||||
- [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
|
||||
|
||||
- [Tailscale](https://tailscale.com/)
|
||||
- [Tailscale GitHub](https://github.com/tailscale/tailscale)
|
||||
- [WireGuard](https://www.wireguard.com/)
|
||||
- [Axis Communications](https://www.axis.com/)
|
||||
|
||||
## License
|
||||
|
||||
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).
|
||||
|
||||
+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.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Third-Party Notices
|
||||
|
||||
This ACAP package redistributes the Tailscale client. The ACAP's own wrapper
|
||||
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
|
||||
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.
|
||||
```
|
||||
+7
-5
@@ -1,12 +1,14 @@
|
||||
ARG ARCH=aarch64
|
||||
ARG VERSION=1.3
|
||||
ARG UBUNTU_VERSION=22.04
|
||||
ARG VERSION=12.10.0
|
||||
ARG UBUNTU_VERSION=24.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
|
||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
|
||||
# Building the ACAP application
|
||||
COPY ./app /opt/app/
|
||||
COPY common/app /opt/app/
|
||||
COPY aarch64/app/manifest.json /opt/app/manifest.json
|
||||
WORKDIR /opt/app
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./
|
||||
ENV EXTRA_CFLAGS=-DHAS_PROXY_PORTS
|
||||
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
To build, from main directory
|
||||
|
||||
docker build --tag aarch64 .
|
||||
docker build --tag aarch64 .
|
||||
|
||||
docker cp $(docker create aarch64):/opt/app ./build
|
||||
docker cp $(docker create aarch64):/opt/app ./build
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
nop:
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Starting Service"
|
||||
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscale
|
||||
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscaled
|
||||
/usr/local/packages/Tailscale_VPN/lib/tailscaled &
|
||||
echo "Service Started"
|
||||
echo "Scroll to Bottom for link"
|
||||
/usr/local/packages/Tailscale_VPN/lib/tailscale up
|
||||
wait
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<head>
|
||||
<h1 style="color:red;">Scroll to the bottom to find authentication URL</h1>
|
||||
<script>
|
||||
function refreshIFrame() {
|
||||
var x = document.getElementById("*Your_iframe_id*");
|
||||
x.contentWindow.location.reload();
|
||||
var t = setTimeout(refreshIFrame, 5000);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload="refreshIFrame()">
|
||||
<iframe id="*Your_iframe_id*" src= "/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+63
-17
@@ -1,21 +1,67 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"schemaVersion": "2.0.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Tailscale - Packaged by Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://www.tailscale.com",
|
||||
"runMode": "once",
|
||||
"version": "1.52.0"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "moshe@mohome.net",
|
||||
"vendorId": "70ee172dd9",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"version": "1.102.4",
|
||||
"architecture": "aarch64",
|
||||
"runMode": "respawn",
|
||||
"compatibleOsVersions": [
|
||||
{
|
||||
"max": "13"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
ARG ARCH=aarch64
|
||||
ARG VERSION=1.15.1
|
||||
ARG UBUNTU_VERSION=22.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
|
||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
|
||||
COPY common/app /opt/app/
|
||||
COPY aarch64_ROOT/app/manifest.json /opt/app/manifest.json
|
||||
WORKDIR /opt/app
|
||||
RUN aarch64-linux-gnu-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
@@ -0,0 +1,5 @@
|
||||
To build, from main directory
|
||||
|
||||
docker build --tag aarch64 .
|
||||
|
||||
docker cp $(docker create aarch64):/opt/app ./build
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"schemaVersion": "1.7.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.102.4",
|
||||
"architecture": "aarch64"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html",
|
||||
"reverseProxy": [
|
||||
{
|
||||
"apiPath": "api",
|
||||
"target": "http://localhost:2201/",
|
||||
"access": "admin"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -1,12 +1,14 @@
|
||||
ARG ARCH=armv7hf
|
||||
ARG VERSION=1.3
|
||||
ARG UBUNTU_VERSION=22.04
|
||||
ARG VERSION=12.10.0
|
||||
ARG UBUNTU_VERSION=24.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
|
||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
|
||||
# Building the ACAP application
|
||||
COPY ./app /opt/app/
|
||||
COPY common/app /opt/app/
|
||||
COPY arm/app/manifest.json /opt/app/manifest.json
|
||||
WORKDIR /opt/app
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build ./
|
||||
ENV EXTRA_CFLAGS=-DHAS_PROXY_PORTS
|
||||
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
To build from main directory
|
||||
|
||||
docker build --tag arm .
|
||||
docker build --tag arm .
|
||||
|
||||
docker cp $(docker create arm):/opt/app ./build
|
||||
docker cp $(docker create arm):/opt/app ./build
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
nop:
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Starting Service"
|
||||
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscale
|
||||
chmod 777 /usr/local/packages/Tailscale_VPN/lib/tailscaled
|
||||
/usr/local/packages/Tailscale_VPN/lib/tailscaled &
|
||||
echo "Service Started"
|
||||
echo "Scroll to Bottom for link"
|
||||
/usr/local/packages/Tailscale_VPN/lib/tailscale up
|
||||
wait
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<head>
|
||||
<h1 style="color:red;">Scroll to the bottom to find authentication URL</h1>
|
||||
<script>
|
||||
function refreshIFrame() {
|
||||
var x = document.getElementById("*Your_iframe_id*");
|
||||
x.contentWindow.location.reload();
|
||||
var t = setTimeout(refreshIFrame, 5000);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload="refreshIFrame()">
|
||||
<iframe id="*Your_iframe_id*" src= "/axis-cgi/admin/systemlog.cgi?appname=Tailscale_VPN" style="height:600px;width:100%;border: none;" ></iframe>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+63
-17
@@ -1,21 +1,67 @@
|
||||
{
|
||||
"schemaVersion": "1.3",
|
||||
"schemaVersion": "2.0.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Tailscale - Packaged by Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://www.tailscale.com",
|
||||
"runMode": "once",
|
||||
"version": "1.52.0"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html"
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "moshe@mohome.net",
|
||||
"vendorId": "70ee172dd9",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"version": "1.102.4",
|
||||
"architecture": "armv7hf",
|
||||
"runMode": "respawn",
|
||||
"compatibleOsVersions": [
|
||||
{
|
||||
"max": "13"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
ARG ARCH=armv7hf
|
||||
ARG VERSION=1.15.1
|
||||
ARG UBUNTU_VERSION=22.04
|
||||
ARG REPO=axisecp
|
||||
ARG SDK=acap-native-sdk
|
||||
|
||||
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
|
||||
|
||||
COPY common/app /opt/app/
|
||||
COPY arm_ROOT/app/manifest.json /opt/app/manifest.json
|
||||
WORKDIR /opt/app
|
||||
RUN arm-linux-gnueabihf-strip -s lib/tailscale lib/tailscaled
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && acap-build -a Tailscale_VPN_run ./
|
||||
@@ -0,0 +1,5 @@
|
||||
To build from main directory
|
||||
|
||||
docker build --tag arm .
|
||||
|
||||
docker cp $(docker create arm):/opt/app ./build
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"schemaVersion": "1.7.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"user": {
|
||||
"group": "root",
|
||||
"username": "root"
|
||||
},
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.102.4",
|
||||
"architecture": "armv7hf"
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html",
|
||||
"reverseProxy": [
|
||||
{
|
||||
"apiPath": "api",
|
||||
"target": "http://localhost:2201/",
|
||||
"access": "admin"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
ARG UBUNTU_VERSION=20.04
|
||||
FROM axisecp/acap-sdk:3.5-armv7hf-ubuntu${UBUNTU_VERSION}
|
||||
|
||||
RUN apt-get update -qq && apt-get install -y --no-install-recommends upx-ucl && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY arm_acap3/app /opt/app/
|
||||
WORKDIR /opt/app
|
||||
|
||||
# The ELF launcher takes over the Tailscale_VPN name.
|
||||
RUN mv Tailscale_VPN start.sh && chmod +x start.sh
|
||||
|
||||
# ACAP 3 elflibcheck needs APPNAME to be ELF and uses pidof(APPNAME) for status.
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && \
|
||||
${CC} -o Tailscale_VPN launcher.c && \
|
||||
${STRIP} -s Tailscale_VPN
|
||||
|
||||
# UPX so the binaries fit on flash
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && \
|
||||
${STRIP} -s lib/tailscale lib/tailscaled 2>/dev/null || true && \
|
||||
upx --best lib/tailscale lib/tailscaled
|
||||
|
||||
# ACAP 3 firmware expects the settings page at the app root, not in html/
|
||||
RUN cp html/index.html index.html
|
||||
|
||||
# Runtime files live in localdata/; symlink them so the web UI can fetch them.
|
||||
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log
|
||||
|
||||
RUN ln -sf ../localdata/status.json html/status.json
|
||||
|
||||
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
|
||||
|
||||
# create-package.sh hardcodes STARTMODE="never" (ignoring package.conf) unless
|
||||
# RESTRICTION_STARTMODE is set, so repack the .eap with respawn.
|
||||
RUN for eap in *.eap; do \
|
||||
tmpdir=$(mktemp -d) && tar xf "$eap" -C "$tmpdir" && \
|
||||
sed -i 's/STARTMODE="never"/STARTMODE="respawn"/' "$tmpdir/package.conf" && \
|
||||
(cd "$tmpdir" && tar czf "/opt/app/$eap" .) && \
|
||||
rm -rf "$tmpdir"; \
|
||||
done
|
||||
@@ -0,0 +1 @@
|
||||
nop:
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/bin/sh
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting Tailscale VPN service (userspace networking)"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
chmod 755 "$APP_DIR/lib/tailscale"
|
||||
chmod 755 "$APP_DIR/lib/tailscaled"
|
||||
|
||||
# Kill any leftover daemon from a previous run
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
|
||||
# Log to file (not piped through logger) -- avoids extra logger process holding
|
||||
# tailscaled stdout open, which prevents our wait loop from detecting exit
|
||||
"$APP_DIR/lib/tailscaled" \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
--socks5-server=localhost:1055 \
|
||||
--outbound-http-proxy-listen=localhost:8080 \
|
||||
--tun=userspace-networking \
|
||||
>>"$STATE_DIR/tailscaled.log" 2>&1 &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
# Wait for socket to appear (up to 15 seconds)
|
||||
i=0
|
||||
while [ $i -lt 15 ] && [ ! -S "$STATE_DIR/tailscaled.sock" ]; do
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
logger -t "Tailscale_VPN" "Connecting to Tailscale network"
|
||||
# --timeout=10s: tailscale up exits promptly after connecting (or giving up),
|
||||
# preventing two large Go binaries running simultaneously and causing OOM on
|
||||
# cameras with limited RAM (e.g. 222 MB).
|
||||
# Capture output so we can extract auth URL and log it to syslog for the web UI.
|
||||
UP_OUT=$("$APP_DIR/lib/tailscale" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
up --hostname="$(hostname)" --timeout=10s 2>&1) || true
|
||||
echo "$UP_OUT" >>"$STATE_DIR/tailscaled.log"
|
||||
|
||||
# If an auth URL was returned, log it so the web UI can show it
|
||||
AUTH_URL=$(echo "$UP_OUT" | grep -o 'https://login\.tailscale\.com/[^ ]*' | head -1)
|
||||
[ -n "$AUTH_URL" ] && logger -t "Tailscale_VPN" "Auth required: $AUTH_URL"
|
||||
|
||||
# Log IP and version into syslog so the web UI details panel can populate
|
||||
TS_IP=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" ip -4 2>/dev/null | head -1)
|
||||
TS_VER=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" version 2>/dev/null | head -1)
|
||||
[ -n "$TS_IP" ] && logger -t "Tailscale_VPN" "Tailscale IP: $TS_IP"
|
||||
[ -n "$TS_VER" ] && logger -t "Tailscale_VPN" "Tailscale version: $TS_VER"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:8080"
|
||||
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
|
||||
|
||||
# Publish tailscale's real backend state as JSON for the web UI to consume.
|
||||
# This is the authoritative connection signal (BackendState / Self.Online /
|
||||
# TailscaleIPs / AuthURL) instead of scraping logs, which otherwise reports
|
||||
# "connected" whenever the launcher keeps the process alive (e.g. no Internet).
|
||||
# Written to localdata and exposed at html/status.json via a build-time symlink.
|
||||
STATUS_FILE="$STATE_DIR/status.json"
|
||||
|
||||
publish_status() {
|
||||
if "$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" status --json >"$STATUS_FILE.tmp" 2>/dev/null; then
|
||||
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
|
||||
chmod 644 "$STATUS_FILE" 2>/dev/null
|
||||
else
|
||||
rm -f "$STATUS_FILE.tmp" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove stale status on stop so the UI does not show a connected node after exit.
|
||||
cleanup() {
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
# Monitoring loop: stay alive while tailscaled is running and keep the published
|
||||
# status fresh. This keeps the parent Tailscale_VPN (C launcher) in the process
|
||||
# table so pidof finds it and the camera web UI shows "Running" instead of "Stopped".
|
||||
while kill -0 "$TAILSCALED_PID" 2>/dev/null; do
|
||||
publish_status
|
||||
sleep 5
|
||||
done
|
||||
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
logger -t "Tailscale_VPN" "tailscaled exited"
|
||||
@@ -0,0 +1,739 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Tailscale VPN</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #181b23;
|
||||
--surface2: #1e2230;
|
||||
--border: #262a35;
|
||||
--text: #e4e6ed;
|
||||
--muted: #8b8fa3;
|
||||
--accent: #2e2d2d;
|
||||
--green: #22c55e;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--radius: 10px;
|
||||
--mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f5f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface2: #f0f1f4;
|
||||
--border: #e0e3e8;
|
||||
--text: #1a1a2e;
|
||||
--muted: #6b7084;
|
||||
--accent: #2e2d2d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.theme-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.theme-btn:hover { color: var(--text); border-color: var(--muted); }
|
||||
.theme-btn svg { width: 16px; height: 16px; }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.status-banner.connected { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); }
|
||||
.status-banner.connecting { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
|
||||
.status-banner.disconnected { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-banner.connected .dot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,0.2); }
|
||||
.status-banner.connecting .dot { background: var(--yellow); box-shadow: 0 0 0 3px rgba(245,158,11,0.2); animation: pulse 1.5s infinite; }
|
||||
.status-banner.disconnected .dot { background: var(--red); box-shadow: 0 0 0 3px rgba(239,68,68,0.2); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-banner.connected .status-text { color: var(--green); }
|
||||
.status-banner.connecting .status-text { color: var(--yellow); }
|
||||
.status-banner.disconnected .status-text { color: var(--red); }
|
||||
|
||||
.status-time {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* Auth block */
|
||||
.auth-block {
|
||||
background: rgba(245,158,11,0.08);
|
||||
border: 1px solid rgba(245,158,11,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.auth-block p {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.auth-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.auth-btn:hover { opacity: 0.9; }
|
||||
.auth-url {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* Info grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.info-item {
|
||||
background: var(--surface2);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.info-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
.info-value.dim { color: var(--muted); font-weight: 400; }
|
||||
|
||||
/* Log viewer */
|
||||
.log-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.log-badge {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.log-toggle {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.log-toggle:hover { text-decoration: underline; }
|
||||
|
||||
.log-box {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-box .log-line { display: block; }
|
||||
.log-box .log-line:hover { background: rgba(46,45,45,0.06); }
|
||||
.log-line .ts { color: var(--muted); opacity: 0.6; }
|
||||
.log-line .msg-info { color: var(--accent); }
|
||||
.log-line .msg-warn { color: var(--yellow); }
|
||||
.log-line .msg-err { color: var(--red); }
|
||||
.log-line .msg-ok { color: var(--green); }
|
||||
|
||||
/* Refresh indicator */
|
||||
.refresh-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Update banner */
|
||||
.update-banner {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 14px;
|
||||
background: rgba(46,45,45,0.1);
|
||||
border: 1px solid rgba(46,45,45,0.2);
|
||||
}
|
||||
.update-banner.visible { display: flex; }
|
||||
.update-banner .update-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.update-banner .update-text strong { color: var(--accent); }
|
||||
.update-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.update-btn:hover { opacity: 0.9; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body { padding: 14px; }
|
||||
.info-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>Tailscale VPN</h1>
|
||||
</div>
|
||||
<button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
|
||||
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
// The syslog header always carries 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; may contain a stale acap-tailscale_vpn name.
|
||||
if (!node) {
|
||||
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
|
||||
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
|
||||
if (nodeMatches) {
|
||||
var nm = nodeMatches[nodeMatches.length - 1].match(/"([^"]+)"/);
|
||||
if (nm) node = nm[1];
|
||||
}
|
||||
}
|
||||
|
||||
var loginMatches = txt.match(/active login:\s+\S+/g);
|
||||
var tailnet = null;
|
||||
if (loginMatches) {
|
||||
var lm = loginMatches[loginMatches.length - 1].match(/active login:\s+(\S+)/);
|
||||
if (lm) tailnet = lm[1];
|
||||
}
|
||||
if (!tailnet) {
|
||||
// Fallback: extract from periodic netmap lines "u=user@email.com"
|
||||
var userMatches = txt.match(/\bu=([^\s\[,\]]+)/g);
|
||||
if (userMatches) {
|
||||
var um = userMatches[userMatches.length - 1].match(/u=([^\s\[,\]]+)/);
|
||||
if (um) tailnet = um[1];
|
||||
}
|
||||
}
|
||||
|
||||
var versionMatches = txt.match(/Program starting: v(\d+\.\d+\.\d+)/g);
|
||||
var version = null;
|
||||
if (versionMatches) {
|
||||
var last = versionMatches[versionMatches.length - 1];
|
||||
var vm = last.match(/v(\d+\.\d+\.\d+)/);
|
||||
if (vm) version = vm[1];
|
||||
}
|
||||
if (!version) {
|
||||
// Fallback: extract from periodic "v1.2.3-tXXX-gYYY peers:" log lines
|
||||
var peersMatches = txt.match(/v(\d+\.\d+\.\d+)-\S+\s+peers:/g);
|
||||
if (peersMatches) {
|
||||
var lp = peersMatches[peersMatches.length - 1];
|
||||
var pm = lp.match(/v(\d+\.\d+\.\d+)/);
|
||||
if (pm) version = pm[1];
|
||||
}
|
||||
}
|
||||
if (!version) {
|
||||
// ACAP3: logged by start.sh as "Tailscale version: 1.96.4"
|
||||
var acap3ver = txt.match(/Tailscale version: (\d+\.\d+\.\d+)/);
|
||||
if (acap3ver) version = acap3ver[1];
|
||||
}
|
||||
|
||||
// ACAP3: extract IP from "Tailscale IP: 100.x.x.x" logged by start.sh
|
||||
if (!tsIP) {
|
||||
var acap3ip = txt.match(/Tailscale IP: (100\.[\d.]+)/);
|
||||
if (acap3ip) tsIP = acap3ip[1];
|
||||
}
|
||||
|
||||
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
|
||||
tsIP = tsIP || cacheGet('ip');
|
||||
node = node || cacheGet('node');
|
||||
tailnet = tailnet || cacheGet('tailnet');
|
||||
version = version || cacheGet('version');
|
||||
|
||||
var stateLines = txt.match(/Switching ipn state [^\n]+/g) || [];
|
||||
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
|
||||
var isRunning = /-> Running/.test(lastState);
|
||||
|
||||
if (!isRunning && stateLines.length === 0) {
|
||||
isRunning = /Tailscale VPN is running/.test(txt) ||
|
||||
/health\(warnable=[^)]+\): ok/.test(txt) ||
|
||||
/derp-\d+ connected/.test(txt) ||
|
||||
/c2n: GET/.test(txt) ||
|
||||
/localapi:/.test(txt);
|
||||
}
|
||||
|
||||
// If an auth URL appears AFTER the last Running state, re-auth is needed
|
||||
if (isRunning && latestUrl) {
|
||||
var lastRunIdx = txt.lastIndexOf('-> Running');
|
||||
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
|
||||
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
|
||||
var urlSnippet = latestUrl.substring(0, 60);
|
||||
var lastUrlIdx = -1, upos = 0, uidx;
|
||||
while ((uidx = txt.indexOf(urlSnippet, upos)) !== -1) { lastUrlIdx = uidx; upos = uidx + 1; }
|
||||
if (lastUrlIdx > lastRunIdx) isRunning = false;
|
||||
}
|
||||
|
||||
if (isRunning) return { state: 'connected', url: null, ip: tsIP, node: node, tailnet: tailnet, version: version };
|
||||
if (latestUrl) return { state: 'connecting', url: latestUrl, ip: null, node: null, tailnet: null, version: version };
|
||||
if (/Starting Tailscale|tailscaled.*start|logtail started/.test(txt)) return { state: 'connecting', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
return { state: 'disconnected', url: null, ip: null, node: null, tailnet: null, version: version };
|
||||
}
|
||||
|
||||
function classifyLine(msg) {
|
||||
if (/error|fail|panic|fatal/i.test(msg)) return 'msg-err';
|
||||
if (/warn|timeout|retry/i.test(msg)) return 'msg-warn';
|
||||
if (/connected|running|logged in|success/i.test(msg)) return 'msg-ok';
|
||||
if (/starting|auth|login|switching/i.test(msg)) return 'msg-info';
|
||||
return '';
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function renderLogs(txt) {
|
||||
var lines = txt.split('\n').filter(function(l) { return l.trim(); });
|
||||
document.getElementById('log-count').textContent = lines.length + ' lines';
|
||||
|
||||
var h = '';
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var parts = lines[i].match(/^(\S+\s+\d+\s+[\d:]+)\s+(.*)/);
|
||||
var cls = classifyLine(lines[i]);
|
||||
if (parts) {
|
||||
h += '<span class="log-line"><span class="ts">' + escHtml(parts[1]) + '</span> <span class="' + cls + '">' + escHtml(parts[2]) + '</span></span>\n';
|
||||
} else {
|
||||
h += '<span class="log-line"><span class="' + cls + '">' + escHtml(lines[i]) + '</span></span>\n';
|
||||
}
|
||||
}
|
||||
logBox.innerHTML = h;
|
||||
if (autoScroll) logBox.scrollTop = logBox.scrollHeight;
|
||||
}
|
||||
|
||||
function render(r) {
|
||||
var banner = document.getElementById('status-banner');
|
||||
var statusText = document.getElementById('status-text');
|
||||
var auth = document.getElementById('auth-block');
|
||||
var info = document.getElementById('info-card');
|
||||
|
||||
banner.className = 'status-banner ' + r.state;
|
||||
var labels = { connected: 'Connected', connecting: 'Connecting...', disconnected: 'Stopped' };
|
||||
statusText.textContent = labels[r.state];
|
||||
|
||||
if (r.state === 'connecting' && r.url) {
|
||||
document.getElementById('auth-link').href = r.url;
|
||||
document.getElementById('auth-url-text').textContent = r.url;
|
||||
auth.style.display = '';
|
||||
} else {
|
||||
auth.style.display = 'none';
|
||||
}
|
||||
|
||||
if (r.state === 'connected') {
|
||||
document.getElementById('ts-ip').textContent = r.ip || '-';
|
||||
document.getElementById('ts-ip').className = 'info-value' + (r.ip ? '' : ' dim');
|
||||
document.getElementById('ts-node').textContent = r.node || '-';
|
||||
document.getElementById('ts-node').className = 'info-value' + (r.node ? '' : ' dim');
|
||||
document.getElementById('ts-tailnet').textContent = r.tailnet || '-';
|
||||
document.getElementById('ts-tailnet').className = 'info-value' + (r.tailnet ? '' : ' dim');
|
||||
document.getElementById('ts-version').textContent = r.version || '-';
|
||||
document.getElementById('ts-version').className = 'info-value' + (r.version ? '' : ' dim');
|
||||
info.style.display = '';
|
||||
if (r.version) checkForUpdate(r.version);
|
||||
} else {
|
||||
info.style.display = 'none';
|
||||
}
|
||||
|
||||
var now = new Date();
|
||||
document.getElementById('status-time').textContent =
|
||||
('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2) + ':' + ('0'+now.getSeconds()).slice(-2);
|
||||
}
|
||||
|
||||
var APP_LIST_URL = '/axis-cgi/applications/list.cgi';
|
||||
|
||||
function checkAppRunning() {
|
||||
return fetch(APP_LIST_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(xml) {
|
||||
var m = xml.match(new RegExp('Name="' + APP + '"[^/]*Status="([^"]+)"'));
|
||||
return m && m[1] === 'Running';
|
||||
})
|
||||
.catch(function() { return false; });
|
||||
}
|
||||
|
||||
// ACAP3: tailscaled's own log has the IP, version, tailnet and Running state.
|
||||
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() {
|
||||
var syslogFetch = fetch(LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); }).catch(function() { return ''; });
|
||||
var daemonFetch = fetch(DAEMON_LOG_URL, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); }).catch(function() { return ''; });
|
||||
|
||||
Promise.all([syslogFetch, daemonFetch, fetchStatus()]).then(function(res) {
|
||||
// Syslog provides Axis timestamp headers (node name) and start/stop events.
|
||||
// tailscaled.log provides IP, version, tailnet, and -> Running state.
|
||||
var txt = res[0] + '\n' + res[1];
|
||||
var st = res[2];
|
||||
var result = parse(txt);
|
||||
renderLogs(res[0]); // daemon log is too verbose to show
|
||||
// Logs can have stale entries; confirm the app is running.
|
||||
checkAppRunning().then(function(running) {
|
||||
if (!running) {
|
||||
result.state = 'disconnected';
|
||||
} else if (st && st.BackendState) {
|
||||
// Authoritative: Tailscale's own backend state
|
||||
applyStatus(result, st);
|
||||
} else if (!result.url && result.state !== 'connected') {
|
||||
// Fallback to log heuristic when status.json is unavailable
|
||||
result.state = 'connected';
|
||||
result.ip = result.ip || cacheGet('ip');
|
||||
result.node = result.node || cacheGet('node');
|
||||
result.tailnet = result.tailnet || cacheGet('tailnet');
|
||||
result.version = result.version || cacheGet('version');
|
||||
}
|
||||
render(result);
|
||||
});
|
||||
}).catch(function() {
|
||||
document.getElementById('status-text').textContent = 'Unable to fetch logs';
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
|
||||
// Check for updates from GitHub
|
||||
var installedVersion = null;
|
||||
var autoChecked = false;
|
||||
function checkForUpdate(currentVersion, manual) {
|
||||
if (!currentVersion) return;
|
||||
installedVersion = currentVersion;
|
||||
if (!manual && autoChecked) return;
|
||||
if (!manual) autoChecked = true;
|
||||
var btn = document.getElementById('check-update-btn');
|
||||
if (manual && btn) btn.textContent = 'Checking...';
|
||||
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var tag = (data.tag_name || '').replace(/^v/, '');
|
||||
if (!tag) return;
|
||||
if (compareVersions(tag, currentVersion) > 0) {
|
||||
document.getElementById('update-version').textContent = 'v' + tag;
|
||||
document.getElementById('update-banner').classList.add('visible');
|
||||
document.getElementById('ts-version').textContent = currentVersion + ' (outdated)';
|
||||
if (btn) btn.textContent = 'Update Available';
|
||||
} else {
|
||||
if (manual && btn) btn.textContent = 'Up to date';
|
||||
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
if (manual && btn) btn.textContent = 'Check failed';
|
||||
setTimeout(function() { if (btn) btn.textContent = 'Check for Updates'; }, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('check-update-btn').addEventListener('click', function() {
|
||||
if (installedVersion) checkForUpdate(installedVersion, true);
|
||||
});
|
||||
|
||||
function compareVersions(a, b) {
|
||||
var pa = a.split('.').map(Number);
|
||||
var pb = b.split('.').map(Number);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
/*
|
||||
* ACAP 3 launcher: elflibcheck needs APPNAME to be ELF, and acap-startstop,
|
||||
* respawnd and list.cgi use pidof(APPNAME). So this stays resident, restarts
|
||||
* start.sh whenever it dies, and only exits on SIGTERM/SIGINT.
|
||||
*/
|
||||
|
||||
static volatile int g_stop = 0;
|
||||
static volatile pid_t g_child = -1;
|
||||
|
||||
static void sig_forward(int sig) {
|
||||
g_stop = 1;
|
||||
if (g_child > 0)
|
||||
kill(g_child, sig);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
signal(SIGTERM, sig_forward);
|
||||
signal(SIGINT, sig_forward);
|
||||
signal(SIGCHLD, SIG_DFL);
|
||||
|
||||
while (!g_stop) {
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
signal(SIGTERM, SIG_DFL);
|
||||
signal(SIGINT, SIG_DFL);
|
||||
execl("/usr/local/packages/Tailscale_VPN/start.sh",
|
||||
"/usr/local/packages/Tailscale_VPN/start.sh", (char *)0);
|
||||
_exit(127);
|
||||
}
|
||||
if (pid < 0) {
|
||||
sleep(5);
|
||||
continue;
|
||||
}
|
||||
g_child = pid;
|
||||
|
||||
int status;
|
||||
pid_t ret;
|
||||
do {
|
||||
ret = waitpid(pid, &status, 0);
|
||||
} while (ret == -1 && errno == EINTR && !g_stop);
|
||||
|
||||
g_child = -1;
|
||||
|
||||
if (!g_stop) {
|
||||
sleep(3);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
PACKAGENAME=Tailscale_VPN
|
||||
MENUNAME="Tailscale VPN"
|
||||
VENDOR="Mo3he"
|
||||
APPMAJORVERSION=1
|
||||
APPMINORVERSION=102
|
||||
APPMICROVERSION=4
|
||||
APPTYPE=armv7hf
|
||||
APPNAME=Tailscale_VPN
|
||||
APPOPTS=""
|
||||
STARTMODE=respawn
|
||||
OTHERFILES="lib html index.html LICENSE start.sh"
|
||||
SETTINGSPAGEFILE=index.html
|
||||
GRPNAME=
|
||||
USERNAME=root
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
PROG = Tailscale_VPN
|
||||
SRCS = param_bridge.c
|
||||
PKGS = axparameter glib-2.0 gio-2.0
|
||||
CFLAGS += $(shell pkg-config --cflags $(PKGS))
|
||||
CFLAGS += $(EXTRA_CFLAGS)
|
||||
LDADD = $(shell pkg-config --libs $(PKGS))
|
||||
|
||||
all: $(PROG)
|
||||
chmod +x Tailscale_VPN_run
|
||||
|
||||
$(PROG): $(SRCS)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
|
||||
|
||||
clean:
|
||||
rm -f $(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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
+582
@@ -0,0 +1,582 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tailscale VPN for Axis Devices</title>
|
||||
<meta name="description" content="Install Tailscale VPN directly on your Axis device. Secure remote access with WireGuard, no extra hardware needed.">
|
||||
|
||||
<!-- Open Graph / LinkedIn -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://mo3he.github.io/Axis_Cam_Tailscale/">
|
||||
<meta property="og:title" content="Tailscale VPN for Axis Devices">
|
||||
<meta property="og:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
|
||||
<meta property="og:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
|
||||
<meta property="og:image:width" content="1340">
|
||||
<meta property="og:image:height" content="724">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Tailscale VPN for Axis Devices">
|
||||
<meta name="twitter:description" content="Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.">
|
||||
<meta name="twitter:image" content="https://mo3he.github.io/Axis_Cam_Tailscale/og-image.png">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #181b23;
|
||||
--border: #262a35;
|
||||
--text: #e4e6ed;
|
||||
--muted: #8b8fa3;
|
||||
--accent: #4f8ff7;
|
||||
--accent-hover: #6ba1ff;
|
||||
--green: #34d399;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f8f9fb;
|
||||
--surface: #ffffff;
|
||||
--border: #e0e3e8;
|
||||
--text: #1a1d27;
|
||||
--muted: #5f6577;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--green: #059669;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
nav .logo {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
nav .links { display: flex; gap: 1.5rem; align-items: center; }
|
||||
nav .links a { color: var(--muted); font-size: 0.95rem; }
|
||||
nav .links a:hover { color: var(--text); }
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--muted); color: var(--text); }
|
||||
.theme-toggle svg { width: 18px; height: 18px; }
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 5rem 2rem 4rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.hero .badge {
|
||||
display: inline-block;
|
||||
background: rgba(79, 143, 247, 0.12);
|
||||
color: var(--accent);
|
||||
padding: 0.35rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(79, 143, 247, 0.2);
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.hero h1 span { color: var(--accent); }
|
||||
.hero-word {
|
||||
display: inline-block;
|
||||
color: var(--accent);
|
||||
transition: opacity 0.25s, transform 0.25s;
|
||||
}
|
||||
.hero p {
|
||||
font-size: 1.2rem;
|
||||
color: var(--muted);
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); color: #fff; }
|
||||
.btn-outline {
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.btn-outline:hover { border-color: var(--muted); color: var(--text); }
|
||||
|
||||
/* Features */
|
||||
.features {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 4rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.feature-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.75rem;
|
||||
}
|
||||
.feature-card .icon {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.feature-card h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.feature-card p {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Downloads */
|
||||
.downloads {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 4rem;
|
||||
}
|
||||
.downloads h2 {
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.downloads .subtitle {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.download-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.download-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.download-card .tag {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.75rem;
|
||||
width: fit-content;
|
||||
}
|
||||
.tag-recommended { background: rgba(52, 211, 153, 0.15); color: var(--green); }
|
||||
.tag-acap3 { background: rgba(168, 85, 247, 0.15); color: #a855f7; }
|
||||
.tag-root { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
|
||||
.download-card h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.download-card p {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
.arch-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.arch-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.arch-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.arch-btn svg { width: 16px; height: 16px; }
|
||||
|
||||
/* Install */
|
||||
.install {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
.install h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.steps {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.step-num {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(79, 143, 247, 0.12);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid rgba(79, 143, 247, 0.2);
|
||||
}
|
||||
.step-text { padding-top: 0.35rem; }
|
||||
.step-text strong { display: block; margin-bottom: 0.15rem; }
|
||||
.step-text span { color: var(--muted); font-size: 0.92rem; }
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
max-width: 1100px;
|
||||
margin: 2rem auto 0;
|
||||
}
|
||||
footer .footer-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero h1 { font-size: 2rem; }
|
||||
.hero { padding: 3rem 1.25rem 2.5rem; }
|
||||
.features, .downloads, .install { padding-left: 1.25rem; padding-right: 1.25rem; }
|
||||
nav { padding: 1rem 1.25rem; flex-wrap: wrap; gap: 0.75rem; }
|
||||
nav .logo { font-size: 1.05rem; }
|
||||
nav .links { gap: 1rem; flex-wrap: wrap; justify-content: center; }
|
||||
nav .links a { font-size: 0.85rem; }
|
||||
.hero-buttons { flex-direction: column; align-items: center; }
|
||||
.download-grid { grid-template-columns: 1fr; }
|
||||
footer .footer-links { flex-wrap: wrap; gap: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="logo">Tailscale VPN + Axis</div>
|
||||
<div class="links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#downloads">Downloads</a>
|
||||
<a href="#install">Install</a>
|
||||
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener" style="color: #db61a2;">Sponsor</a>
|
||||
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">
|
||||
<svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<svg id="iconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="hero">
|
||||
<div class="badge">Open Source · ACAP Package</div>
|
||||
<h1>Tailscale VPN for<br>Axis <span class="hero-word" id="heroWord">Cameras</span></h1>
|
||||
<p>Secure remote access to your Axis devices over WireGuard. No extra hardware, no complex network config - just install and connect.</p>
|
||||
<div class="hero-buttons">
|
||||
<a href="#downloads" class="btn btn-primary">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download EAP
|
||||
</a>
|
||||
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" class="btn btn-outline" target="_blank" rel="noopener">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.73.083-.73 1.205.085 1.838 1.237 1.838 1.237 1.07 1.834 2.807 1.304 3.492.997.108-.775.418-1.305.762-1.604-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.468-2.382 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23a11.5 11.5 0 0 1 3.003-.404c1.02.005 2.047.138 3.006.404 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.838 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.694.825.576C20.565 21.795 24 17.295 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="max-width:720px;margin:0 auto;padding:0 1.5rem 1.5rem;">
|
||||
<div style="background:rgba(79,143,247,0.07);border:1px solid rgba(79,143,247,0.18);border-radius:10px;padding:12px 18px;font-size:12.5px;color:var(--muted);line-height:1.6;">
|
||||
<strong style="color:var(--text);">Disclaimer:</strong>
|
||||
This is an independent, community-developed ACAP package and is <strong style="color:var(--text);">not an official Axis Communications product</strong>.
|
||||
It is not affiliated with, endorsed by, or supported by Axis Communications AB.
|
||||
Use at your own risk. For official Axis software, visit <a href="https://www.axis.com" target="_blank" rel="noopener">axis.com</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="features" class="features">
|
||||
<div class="feature-card">
|
||||
<div class="icon">🔒</div>
|
||||
<h3>WireGuard Encryption</h3>
|
||||
<p>Built on Tailscale's WireGuard-based tunnel for fast, modern, and auditable encryption on every connection.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">⚡</div>
|
||||
<h3>No Root Required</h3>
|
||||
<p>Runs in user-space networking mode on Axis OS 12+. No need to enable root access on your device.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">📦</div>
|
||||
<h3>Simple EAP Install</h3>
|
||||
<p>Upload the .eap file through your device's web interface. Start the app and authenticate - done.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🔄</div>
|
||||
<h3>Weekly Auto-Updates</h3>
|
||||
<p>New EAP packages are automatically built and released every week when a new Tailscale version is available.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🌐</div>
|
||||
<h3>Headscale Compatible</h3>
|
||||
<p>Supports self-hosted Headscale servers with configurable server URL and auth key, built into every variant.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🔀</div>
|
||||
<h3>Outbound Proxy</h3>
|
||||
<p>Allows the device to route its own outbound traffic through Tailscale via a local HTTP/HTTPS and SOCKS5 proxy.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="downloads" class="downloads">
|
||||
<h2>Download</h2>
|
||||
<p class="subtitle">Pick the right variant for your device and Axis OS version.</p>
|
||||
<div class="download-grid">
|
||||
<div class="download-card">
|
||||
<div class="tag tag-recommended">Recommended</div>
|
||||
<h3>Standard (Non-Root)</h3>
|
||||
<p>For Axis OS 12 and newer. Runs in user-space networking mode without root privileges.</p>
|
||||
<div class="arch-buttons">
|
||||
<a class="arch-btn" data-asset="Tailscale_VPN" data-arch="aarch64" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
AARCH64
|
||||
</a>
|
||||
<a class="arch-btn" data-asset="Tailscale_VPN" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
ARM
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-card">
|
||||
<div class="tag tag-root">Root · Legacy</div>
|
||||
<h3>Root (Full Networking)</h3>
|
||||
<p>For Axis OS older than 12. Requires root access but provides full Tailscale networking features.</p>
|
||||
<div class="arch-buttons">
|
||||
<a class="arch-btn" data-asset="ROOT" data-arch="aarch64" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
AARCH64
|
||||
</a>
|
||||
<a class="arch-btn" data-asset="ROOT" data-arch="armv7hf" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
ARM
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div 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>
|
||||
</section>
|
||||
|
||||
<section id="install" class="install">
|
||||
<h2>Installation</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-text">
|
||||
<strong>Download the EAP</strong>
|
||||
<span>Grab the right .eap file for your device architecture from the releases page above.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-text">
|
||||
<strong>Upload to your device</strong>
|
||||
<span>Log into your Axis device web interface and go to <strong>Apps → Add App</strong>. Upload the .eap file.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-text">
|
||||
<strong>Start & authenticate</strong>
|
||||
<span>Start the app, click <strong>Open</strong> to view logs and get your Tailscale authentication URL. Sign in and you're connected.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases" target="_blank" rel="noopener">Releases</a>
|
||||
<a href="https://github.com/sponsors/Mo3he" target="_blank" rel="noopener">Sponsor</a>
|
||||
<a href="https://buymeacoffee.com/mo3he" target="_blank" rel="noopener">Buy Me a Coffee</a>
|
||||
<a href="https://tailscale.com/" target="_blank" rel="noopener">Tailscale</a>
|
||||
<a href="https://www.axis.com/" target="_blank" rel="noopener">Axis</a>
|
||||
</div>
|
||||
<p>© 2025 Mo3he · MIT License</p>
|
||||
<p style="font-size:0.75rem;opacity:0.5;margin-top:0.5rem;">Tailscale is a product of Tailscale Inc. This is an independent, unofficial package that redistributes Tailscale binaries under the <a href="https://github.com/Mo3he/Axis_Cam_Tailscale/blob/main/LICENSE" target="_blank" rel="noopener" style="color:inherit;">BSD 3-Clause License</a>. Not affiliated with or endorsed by Tailscale Inc. or Axis Communications AB.</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var toggle = document.getElementById('themeToggle');
|
||||
var sun = document.getElementById('iconSun');
|
||||
var moon = document.getElementById('iconMoon');
|
||||
var html = document.documentElement;
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'light') {
|
||||
html.setAttribute('data-theme', 'light');
|
||||
sun.style.display = 'none';
|
||||
moon.style.display = 'block';
|
||||
} else {
|
||||
html.removeAttribute('data-theme');
|
||||
sun.style.display = 'block';
|
||||
moon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
var stored = localStorage.getItem('theme');
|
||||
if (stored) {
|
||||
applyTheme(stored);
|
||||
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
applyTheme('light');
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', function() {
|
||||
var isLight = html.getAttribute('data-theme') === 'light';
|
||||
var next = isLight ? 'dark' : 'light';
|
||||
localStorage.setItem('theme', next);
|
||||
applyTheme(next);
|
||||
});
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function(e) {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
applyTheme(e.matches ? 'light' : 'dark');
|
||||
}
|
||||
});
|
||||
|
||||
var heroWord = document.getElementById('heroWord');
|
||||
var devices = ['Cameras', 'Door Stations', 'Intercoms', 'Speakers', 'Radars', 'Encoders'];
|
||||
var wordIdx = 0;
|
||||
function cycleWord() {
|
||||
wordIdx = (wordIdx + 1) % devices.length;
|
||||
heroWord.style.opacity = '0';
|
||||
heroWord.style.transform = 'translateY(8px)';
|
||||
setTimeout(function() {
|
||||
heroWord.textContent = devices[wordIdx];
|
||||
heroWord.style.opacity = '1';
|
||||
heroWord.style.transform = 'translateY(0)';
|
||||
}, 250);
|
||||
}
|
||||
setInterval(cycleWord, 3000);
|
||||
|
||||
// Rewrite download links to point directly to latest release assets
|
||||
fetch('https://api.github.com/repos/Mo3he/Axis_Cam_Tailscale/releases/latest')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var assets = data.assets || [];
|
||||
var buttons = document.querySelectorAll('a[data-asset]');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
var btn = buttons[i];
|
||||
var type = btn.getAttribute('data-asset');
|
||||
var arch = btn.getAttribute('data-arch');
|
||||
for (var j = 0; j < assets.length; j++) {
|
||||
var name = assets[j].name.toLowerCase();
|
||||
var archMatch = name.indexOf(arch) !== -1;
|
||||
var typeMatch = false;
|
||||
if (type === 'Tailscale_VPN') {
|
||||
typeMatch = name.indexOf('root') === -1 && name.indexOf('acap3') === -1;
|
||||
} else if (type === 'acap3') {
|
||||
typeMatch = name.indexOf('acap3') !== -1;
|
||||
} else if (type === 'ROOT') {
|
||||
typeMatch = name.indexOf('root') !== -1;
|
||||
}
|
||||
if (archMatch && typeMatch) {
|
||||
btn.href = assets[j].browser_download_url;
|
||||
btn.removeAttribute('target');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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