mirror of
https://github.com/Mo3he/Axis_Cam_Tailscale.git
synced 2026-10-01 03:55:40 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a71aba342 | ||
|
|
dce8beaa1b | ||
|
|
b70d664f91 | ||
|
|
9ea88ccd0d | ||
|
|
f79f631d95 | ||
|
|
835ccff82f | ||
|
|
92262bd16c | ||
|
|
948c201ea3 | ||
|
|
3730609abe | ||
|
|
b06dbc0059 | ||
|
|
7d32827567 | ||
|
|
970055051d | ||
|
|
216ae1ebf9 | ||
|
|
e2daee6519 | ||
|
|
2a8dccfc05 | ||
|
|
02dda93272 | ||
|
|
0331f8d8c5 | ||
|
|
f3d649aa92 | ||
|
|
bddcdf7de8 | ||
|
|
47c3894002 |
+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
|
||||
@@ -110,17 +110,27 @@ jobs:
|
||||
mkdir -p releases
|
||||
|
||||
for folder in */ ; do
|
||||
[[ ! -d "$folder/app" ]] && continue
|
||||
FOLDER_NAME="${folder%/}" # remove trailing slash
|
||||
[[ "$FOLDER_NAME" == "common" ]] && continue
|
||||
[[ ! -d "$folder/app" ]] && continue
|
||||
echo "Processing folder $FOLDER_NAME"
|
||||
|
||||
# aarch64/arm/aarch64_ROOT/arm_ROOT share their C source, run script,
|
||||
# HTML, and Makefile via common/app/ (see Dockerfile COPY layers);
|
||||
# only arm_acap3 still carries its own self-contained app/ tree.
|
||||
case "$FOLDER_NAME" in
|
||||
aarch64|arm|aarch64_ROOT|arm_ROOT) APP_LIB_DIR="common/app/lib" ;;
|
||||
*) APP_LIB_DIR="$folder/app/lib" ;;
|
||||
esac
|
||||
mkdir -p "$APP_LIB_DIR"
|
||||
|
||||
# Detect architecture
|
||||
if [[ "$FOLDER_NAME" == arm* ]]; then
|
||||
cp tailscale_bins/tailscale_arm "$folder/app/lib/tailscale"
|
||||
cp tailscale_bins/tailscaled_arm "$folder/app/lib/tailscaled"
|
||||
cp tailscale_bins/tailscale_arm "$APP_LIB_DIR/tailscale"
|
||||
cp tailscale_bins/tailscaled_arm "$APP_LIB_DIR/tailscaled"
|
||||
else
|
||||
cp tailscale_bins/tailscale_arm64 "$folder/app/lib/tailscale"
|
||||
cp tailscale_bins/tailscaled_arm64 "$folder/app/lib/tailscaled"
|
||||
cp tailscale_bins/tailscale_arm64 "$APP_LIB_DIR/tailscale"
|
||||
cp tailscale_bins/tailscaled_arm64 "$APP_LIB_DIR/tailscaled"
|
||||
fi
|
||||
|
||||
# Detect variant suffix for .eap naming
|
||||
@@ -145,7 +155,7 @@ jobs:
|
||||
# Docker build
|
||||
TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean
|
||||
echo "Building $TAG_NAME"
|
||||
docker build -f "$folder/Dockerfile" --tag "$TAG_NAME" "$folder"
|
||||
docker build -f "$folder/Dockerfile" --tag "$TAG_NAME" .
|
||||
|
||||
# Extract .eap files into build folder
|
||||
EAP_OUTPUT="./build/${TAG_NAME}"
|
||||
|
||||
@@ -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@v6
|
||||
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 }}
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
**/.DS_Store
|
||||
**/build
|
||||
|
||||
# Do not track release artifacts
|
||||
# Do not track release artifacts (local .eap build outputs stay untracked anywhere in the tree)
|
||||
releases/
|
||||
build/
|
||||
*.eap
|
||||
|
||||
# Do not track downloaded Tailscale tarballs and temp bins
|
||||
tailscale_bins/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# Enforce error-level Dockerfile correctness. Warnings/info are advisory: the
|
||||
# ACAP cross-compile Dockerfiles use accepted patterns (cd in RUN, ARG-templated
|
||||
# FROM tags hadolint cannot resolve, optional pipefail).
|
||||
failure-threshold: error
|
||||
ignored:
|
||||
# Pin versions in 'apt-get install' - the SDK base image is already pinned.
|
||||
- DL3008
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
# Line length (disabled: long lines in tables, URLs and prose are acceptable)
|
||||
MD013: false
|
||||
# Allow inline HTML (e.g. <img> logos and badges in READMEs)
|
||||
MD033: false
|
||||
# Allow blank lines inside blockquotes
|
||||
MD028: false
|
||||
# First line in a file should be a top-level heading
|
||||
MD041: false
|
||||
# Table column style (disabled; the previous "padded" value was invalid)
|
||||
MD060: false
|
||||
@@ -0,0 +1,2 @@
|
||||
rules:
|
||||
line-length: disable
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# 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.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
|
||||
+14
-14
@@ -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?
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
Copyright (c) 2022 Weston Blieden (ACAP packaging and wrapper code)
|
||||
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:
|
||||
@@ -27,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,73 +1,114 @@
|
||||
# Tailscale ACAP for Axis Cameras
|
||||
|
||||
[](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
|
||||
[](https://github.com/Mo3he/Axis_Cam_Tailscale/actions/workflows/build.yml)
|
||||
[](https://github.com/Mo3he/Axis_Cam_Tailscale/actions/workflows/super-linter.yml)
|
||||
[](https://github.com/sponsors/Mo3he)
|
||||
[](https://www.buymeacoffee.com/mo3he)
|
||||
|
||||
This repository provides an **ACAP package** that installs the
|
||||
[Tailscale VPN client](https://tailscale.com/) on Axis cameras, for secure remote
|
||||
access without extra hardware or complex network configuration.
|
||||
|
||||
**[Visit the Homepage](https://mo3he.github.io/Axis_Cam_Tailscale/)**
|
||||
|
||||
This repository provides an **ACAP package** that installs the [Tailscale VPN client](https://tailscale.com/) on Axis cameras.
|
||||
> **Disclaimer:** Independent, community-developed ACAP package. Not an official
|
||||
> Axis product and not affiliated with, endorsed by, or supported by Axis
|
||||
> Communications AB or Tailscale Inc. Use at your own risk.
|
||||
|
||||
- Secure remote access to cameras
|
||||
- Easy to install via EAP package
|
||||
- Works on **Axis OS 11.11+** (non-root version)
|
||||
- Works on **legacy Axis OS 9.x / 10.x** via the ACAP 3 variant
|
||||
- Based on **WireGuard VPN** technology
|
||||
|
||||
[](https://github.com/Mo3he/Axis_Cam_Tailscale/releases)
|
||||
[](LICENSE)
|
||||

|
||||
[](https://github.com/sponsors/Mo3he)
|
||||
[](https://www.buymeacoffee.com/mo3he)
|
||||
|
||||
> **Disclaimer:** This is an independent, community-developed ACAP package and is not an official Axis Communications product. It is not affiliated with, endorsed by, or supported by Axis Communications AB. Use it at your own risk. For official Axis software, visit axis.com
|
||||
|
||||
> **Tailscale Notice:** Tailscale is a product of Tailscale Inc. This package independently redistributes the Tailscale binaries under the [BSD 3-Clause License](LICENSE) and is not affiliated with, endorsed by, or supported by Tailscale Inc. For the official Tailscale client, visit [tailscale.com](https://tailscale.com).
|
||||
---
|
||||
> **Tailscale Notice:** Tailscale is a product of Tailscale Inc. This package
|
||||
> independently redistributes the Tailscale binaries under the
|
||||
> [BSD 3-Clause License](LICENSE) and is not affiliated with, endorsed by, or
|
||||
> supported by Tailscale Inc. For the official Tailscale client, visit
|
||||
> [tailscale.com](https://tailscale.com).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Settings](#settings)
|
||||
- [Proxy Support](#proxy-support)
|
||||
- [Accessing Tailnet Services from the Camera](#accessing-tailnet-services-from-the-camera)
|
||||
- [Updating Tailscale](#updating-tailscale)
|
||||
- [Purpose](#purpose)
|
||||
- [Useful Links](#useful-links)
|
||||
- [Compatibility](#compatibility)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Star History](#star-history)
|
||||
- [Support](#support)
|
||||
- [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)
|
||||
|
||||
---
|
||||
## Overview
|
||||
|
||||
Adding a VPN client directly to the camera enables secure remote access without
|
||||
additional hardware or complex network configuration, through Tailscale's
|
||||
lightweight WireGuard-based tunnel.
|
||||
|
||||
- Secure remote access to cameras.
|
||||
- Easy to install via EAP package.
|
||||
- Works on **AXIS OS 10.12+** (non-root version, verified across 10.12–13).
|
||||
- Works on **legacy AXIS OS 9.x / 10.x** via the ACAP 3 variant.
|
||||
- Based on **WireGuard VPN** technology.
|
||||
|
||||
Tailscale ACAP runs **without root privileges** in userspace networking mode,
|
||||
making it compatible with AXIS OS 10.12+. For **full kernel networking**, use the
|
||||
**ROOT** version (AXIS OS 10.12–11.x only; AXIS OS 12 and later removed root
|
||||
access for third-party applications). Learn more:
|
||||
[How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/).
|
||||
|
||||
## Compatibility
|
||||
|
||||
| Build | AXIS OS | Architecture | Notes |
|
||||
|---|---|---|---|
|
||||
| ACAP 4 (native SDK) | 10.12 – 13 | aarch64 | Standard, userspace networking |
|
||||
| ACAP 4 (native SDK) | 10.12 – 13 | armv7hf | Standard, userspace networking |
|
||||
| ACAP 4 root | 10.12 – 11.x | aarch64 | Full kernel networking (not on OS 12+) |
|
||||
| ACAP 4 root | 10.12 – 11.x | armv7hf | Full kernel networking (not on OS 12+) |
|
||||
| ACAP 3 (legacy SDK) | 9.x – 10.x | armv7hf | Legacy cameras |
|
||||
|
||||
> Most cameras use the standard **ACAP 4** build. The **root** builds add
|
||||
> kernel-level networking but only run on AXIS OS 10.12–11.x (AXIS OS 12+ removed
|
||||
> root access for ACAPs). Use the **ACAP 3** build only on legacy cameras that
|
||||
> don't support ACAP 4 (AXIS OS 9–10).
|
||||
|
||||
**Verified on AXIS OS 13** (13.0.0, aarch64).
|
||||
|
||||
## Installation
|
||||
|
||||
Get the **prebuilt `.eap` file** from the [Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
|
||||
> **Signed packages:** Release `.eap` files are signed with the Axis ACAP
|
||||
> signing service and install normally on AXIS OS 12.10 and later.
|
||||
>
|
||||
> **Upgrading from an earlier version?** The signing vendor changed, so
|
||||
> installing over a previously installed unsigned build can fail with
|
||||
> **"Couldn't install: app"** (device log: *"Vendor ID in manifest does not
|
||||
> match the vendor ID of the previous version"*). To upgrade: back up your app
|
||||
> configuration, **uninstall** the old version, then install the signed one.
|
||||
|
||||
1. Log into your Axis camera.
|
||||
2. Go to **Apps → Add App**.
|
||||
3. Upload the `.eap` file.
|
||||
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.
|
||||
|
||||
- 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
|
||||
|
||||
## Usage
|
||||
The app runs a C-based parameter bridge that reads settings from the ACAP
|
||||
parameter store and launches Tailscale. View logs and connection status via the
|
||||
**Open** button in the app, and authenticate using the provided URL or by
|
||||
pre-entering an auth key in **Settings**. Parameter changes (ports, server URL,
|
||||
auth key) are applied automatically without reinstalling the app.
|
||||
|
||||
- Runs a C-based parameter bridge (compiled via ACAP SDK 1.15.1) that reads settings from the ACAP parameter store and launches Tailscale.
|
||||
- View logs and connection status via the **Open** button in the app.
|
||||
- Authenticate using the provided URL, or pre-enter an auth key in **Settings**.
|
||||
- Change the **Custom Server URL** in Settings to use a self-hosted [Headscale](https://headscale.net/) control server.
|
||||
- Parameter changes (ports, server URL, auth key) are applied automatically without needing to reinstall the app.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
All parameters are configurable via the web UI (**Open → Settings** card) and take effect immediately without reinstalling:
|
||||
All parameters are configurable via the web UI (**Open -> Settings** card) and
|
||||
take effect immediately:
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
@@ -75,173 +116,140 @@ All parameters are configurable via the web UI (**Open → Settings** card) and
|
||||
| Auth Key | *(empty)* | Pre-authentication key (`tskey-auth-...`). Cleared automatically after first successful connection. Leave blank to authenticate via browser. |
|
||||
| HTTP Proxy Port | `8080` | Port for the outbound HTTP/HTTPS proxy. |
|
||||
| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 proxy. |
|
||||
| Accept DNS | `off` | Passes `--accept-dns=true` to `tailscale up`. Allows the tailnet to push DNS settings to the camera. Off by default to avoid overriding the camera's DNS configuration. Not available on `armv7hf_acap3`. |
|
||||
| Accept Routes | `off` | Passes `--accept-routes=true` to `tailscale up`. Allows the camera to use subnet routes advertised by other nodes in the tailnet. Not available on `armv7hf_acap3`. |
|
||||
| Accept DNS | `off` | Passes `--accept-dns=true` to `tailscale up`. Allows the tailnet to push DNS settings to the camera. Not available on `armv7hf_acap3`. |
|
||||
| Accept Routes | `off` | Passes `--accept-routes=true` to `tailscale up`. Allows the camera to use subnet routes advertised by other nodes. Not available on `armv7hf_acap3`. |
|
||||
| Advertise Routes (Subnet Router) | *(empty)* | Comma-separated CIDRs (e.g. `192.168.1.0/24,10.0.0.0/8`) this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving. Leave blank to disable. |
|
||||
|
||||
---
|
||||
## Ports & security
|
||||
|
||||
All non-ROOT variants expose two local proxy endpoints that route outbound
|
||||
traffic through the Tailscale tunnel. The ports are configurable via **Settings
|
||||
-> HTTP Proxy Port / SOCKS5 Proxy Port**.
|
||||
|
||||
All non-ROOT variants expose two local proxy endpoints that route outbound traffic through the Tailscale tunnel. The ports are configurable via **Settings → HTTP Proxy Port / SOCKS5 Proxy Port** in the web UI.
|
||||
|
||||
### HTTP CONNECT Proxy — `http://127.0.0.1:8080` (default)
|
||||
|
||||
Routes HTTP and HTTPS traffic. Set this wherever an HTTP/HTTPS proxy field is available on the camera:
|
||||
|
||||
| Location | Field | Value |
|
||||
| Proxy | Default address | Routes |
|
||||
|---|---|---|
|
||||
| System → Network → Global proxies | HTTP proxy | `http://127.0.0.1:<port>` |
|
||||
| System → Network → Global proxies | HTTPS proxy | `http://127.0.0.1:<port>` |
|
||||
| System → MQTT → Broker | HTTP proxy | `http://127.0.0.1:<port>` |
|
||||
| System → MQTT → Broker | HTTPS proxy | `http://127.0.0.1:<port>` |
|
||||
| HTTP CONNECT | `http://127.0.0.1:8080` | HTTP and HTTPS traffic |
|
||||
| SOCKS5 | `127.0.0.1:1080` | Any SOCKS5-aware app or service |
|
||||
|
||||
### SOCKS5 Proxy — `127.0.0.1:1080` (default)
|
||||
Set the HTTP CONNECT proxy wherever an HTTP/HTTPS proxy field is available on the
|
||||
camera (System -> Network -> Global proxies; System -> MQTT -> Broker). For
|
||||
SOCKS5-aware apps, set their proxy to `127.0.0.1:<port>`.
|
||||
|
||||
For ACAP apps or services that support SOCKS5, set their proxy to `127.0.0.1:<port>`.
|
||||
> **Security:** the proxies bind to **loopback only** (`127.0.0.1`), so they are
|
||||
> not exposed on the camera's network interface, the least-exposed of the VPN
|
||||
> ACAPs. The active proxy addresses are shown in the **Proxy Configuration** card
|
||||
> of the web UI. If you change a port that is already in use, the app logs an
|
||||
> error and exits rather than silently falling back.
|
||||
|
||||
> The active proxy addresses are always shown in the **Proxy Configuration** card of the web UI.
|
||||
## Accessing Tailnet services from the camera
|
||||
|
||||
> If you change a port that is already in use by another process, the app will log an error and exit rather than silently falling back to a different port.
|
||||
|
||||
---
|
||||
|
||||
## Accessing Tailnet Services from the Camera
|
||||
|
||||
There is an important asymmetry to understand. 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 network share hosted on another node. How well this works depends on which build you use.
|
||||
|
||||
### Why the build matters
|
||||
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` interface) | Only through the local **SOCKS5 / HTTP proxies**, and only for **proxy-aware** apps. Firmware system services (the SMB share client, NTP, etc.) are proxy-unaware, so they **cannot** reach a peer's `100.x` Tailscale IP directly. |
|
||||
| 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. |
|
||||
|
||||
In short: on non-root builds the proxies cover apps that know how to use a proxy, but a system feature like "add network share" opens a raw socket that never touches the tunnel. The ROOT build is the clean way to let the camera *consume* tailnet services.
|
||||
|
||||
### 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, you can 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.
|
||||
> **Requires root on the camera.** Port 445 is privileged, so binding it needs a
|
||||
> root-capable build (e.g. developer certificates installed).
|
||||
|
||||
From a computer that has both the share and tailnet access to the camera:
|
||||
If you cannot use the ROOT build but still need the camera to mount a share on a
|
||||
machine that is on your tailnet, make the remote share appear **local** to the
|
||||
camera with a reverse SSH tunnel. Because the destination becomes `127.0.0.1`, the
|
||||
proxy-unaware SMB client never has to route over the tailnet.
|
||||
|
||||
```bash
|
||||
# Forward the camera's local port 445 back to the SMB share on this machine
|
||||
ssh -R 445:localhost:445 root@<camera-tailscale-ip>
|
||||
```
|
||||
|
||||
Then, in the camera's **System → Storage → Add network share** dialog, use `127.0.0.1` as the share host and connect.
|
||||
|
||||
---
|
||||
Then, in **System -> Storage -> Add network share**, use `127.0.0.1` as the share
|
||||
host and connect.
|
||||
|
||||
## Updating Tailscale
|
||||
|
||||
- New `.eap` files are auto-built and released **weekly** (if a new Tailscale version is available).
|
||||
- To update, simply install the new `.eap` over the existing one.
|
||||
- New `.eap` files are auto-built and released **weekly** (if a new Tailscale
|
||||
version is available).
|
||||
- To update, simply install the new `.eap` over the existing one.
|
||||
|
||||
### Manual update (advanced)
|
||||
|
||||
Replace the binaries in the `lib/` folder:
|
||||
Replace the binaries in `common/app/lib/` (shared by `aarch64`, `armv7hf`, and
|
||||
their ROOT variants) or `arm_acap3/app/lib/` (legacy variant, kept separate):
|
||||
|
||||
- `tailscale`
|
||||
- `tailscaled`
|
||||
|
||||
Download the latest versions: [Tailscale static builds](https://pkgs.tailscale.com/stable/#static)
|
||||
Download the latest versions:
|
||||
[Tailscale static builds](https://pkgs.tailscale.com/stable/#static).
|
||||
|
||||
#### Build locally
|
||||
## Build from source
|
||||
|
||||
From the main directory of the version you want (`arm` / `aarch64`):
|
||||
The Tailscale binaries are not stored in git, so first download them (see
|
||||
[Manual update](#manual-update-advanced)) and place them in `common/app/lib/`, or
|
||||
`arm_acap3/app/lib/` for the legacy variant.
|
||||
|
||||
All variants build from the **repository root**, pointing at the variant's own
|
||||
`Dockerfile`:
|
||||
|
||||
```bash
|
||||
docker build --tag <package_name> .
|
||||
docker build -f aarch64/Dockerfile --tag <package_name> .
|
||||
docker cp $(docker create <package_name>):/opt/app ./build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Good News
|
||||
|
||||
Tailscale ACAP can now run **without root privileges**, making it compatible with **Axis OS 11.11+**.
|
||||
|
||||
- Runs in **user space networking mode**.
|
||||
|
||||
For **full kernel networking**, use the **ROOT** version. Note: ROOT mode requires Axis OS 11.11–11.x — Axis OS 12 and later removed root access for third-party applications.
|
||||
|
||||
### Legacy camera support (Axis OS 9.x / 10.x)
|
||||
|
||||
An **ACAP 3** variant (`armv7hf_acap3`) is available for older cameras that do not support ACAP 4 / Axis OS 11+. It uses the same userspace networking mode and web UI, built against the ACAP SDK 3.5 toolchain.
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Adding a VPN client directly to the camera enables:
|
||||
- Secure remote access without additional hardware or complex network configuration.
|
||||
- Easy setup through Tailscale’s lightweight WireGuard-based tunnel.
|
||||
|
||||
Learn more: [How Tailscale Works](https://tailscale.com/blog/how-tailscale-works/)
|
||||
|
||||
---
|
||||
|
||||
## Useful Links
|
||||
|
||||
- [Tailscale](https://tailscale.com/)
|
||||
- [Tailscale GitHub](https://github.com/tailscale/tailscale)
|
||||
- [WireGuard](https://www.wireguard.com/)
|
||||
- [Axis Communications](https://www.axis.com/)
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
The Tailscale ACAP is compatible with Axis cameras with **ARM** and **AARCH64**-based SoCs.
|
||||
|
||||
| Variant | Architecture | Axis OS | Notes |
|
||||
|---|---|---|---|
|
||||
| `aarch64` | AArch64 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports |
|
||||
| `armv7hf` | ARMv7 | 11.11+ (ACAP 4) | Standard, userspace networking, configurable proxy ports |
|
||||
| `aarch64_root` | AArch64 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
|
||||
| `armv7hf_root` | ARMv7 | 11.11 – 11.x (ACAP 4) | Full kernel networking (root) — not supported on OS 12+ |
|
||||
| `armv7hf_acap3` | ARMv7 | **9.x – 10.x** | Legacy cameras, ACAP SDK 3 |
|
||||
|
||||
> Not sure which variant to use? Check **System → Properties → Firmware version** on your camera. Axis OS 12+ → use the standard variant (`aarch64` or `armv7hf`). Axis OS 11.11–11.x → standard variant, or ROOT if you need kernel networking. Axis OS 9.x/10.x on ARMv7 → use `armv7hf_acap3`.
|
||||
|
||||
You can verify your device details using the following command:
|
||||
|
||||
```bash
|
||||
curl --anyauth "*" -u <username>:<password> <device_ip>/axis-cgi/basicdeviceinfo.cgi --data '{"apiVersion":"1.0","context":"Client defined request ID","method":"getAllProperties"}'
|
||||
```
|
||||
|
||||
> Replace `<device_ip>`, `<username>`, and `<password>` with your device credentials.
|
||||
> Enclose your password in quotes `' '` if it contains special characters.
|
||||
|
||||
---
|
||||
(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. The following items are required to maintain compatibility. See the full [AXIS OS 13 breaking changes](https://www.axis.com/for-developers/news/AXIS-OS-13-breaking-changes) announcement for details.
|
||||
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.
|
||||
|
||||
- [ ] **Recompile for 64-bit time (Y2038)** - AXIS OS 13 switches to a 64-bit time interface. All ACAP apps must be recompiled against the updated SDK. Cameras with incompatible apps installed will roll back the OS upgrade rather than proceed.
|
||||
- [ ] **Sign the ACAP via the Axis ACAP Portal** - AXIS OS 13 removes the ability to install unsigned applications in production environments. The app must be submitted and signed through the official Axis ACAP Portal to remain installable.
|
||||
- [ ] **Migrate to Manifest Schema v2** - The `manifest.json` must use Manifest Schema v2, including an explicit declaration of compatible AXIS OS versions, to satisfy the new signing and compatibility requirements.
|
||||
- [ ] **Audit for executable stack usage** - Any ACAP compiled with an executable stack must be recompiled to comply with the new security restrictions in AXIS OS 13.
|
||||
- [ ] **Verify web UI works over HTTPS** - AXIS OS 13 enforces HTTPS-only connections by default. The bundled web UI must be tested to confirm it functions correctly under this constraint.
|
||||
- [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** - Add an opt-in setting to the settings page that passes `--accept-dns=true` to `tailscale up`. Defaults to off to prevent Tailscale from overriding `resolv.conf` on cameras that don't need MagicDNS.
|
||||
- [x] **Accept routes toggle** - Add an opt-in setting that passes `--accept-routes=true` to `tailscale up`, allowing the camera to use subnet routes advertised by other nodes in the tailnet.
|
||||
- [ ] **Switch to tiny-tailscale binaries** - Evaluate replacing the bundled `tailscale` and `tailscaled` binaries with [tiny-tailscale](https://github.com/iamromulan/tiny-tailscale) builds. These combine both into a single binary, strip unused features, and are significantly smaller (~43% reduction), reducing install size and memory footprint across all architectures.
|
||||
- [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
|
||||
|
||||
## Star History
|
||||
- [Tailscale](https://tailscale.com/)
|
||||
- [Tailscale GitHub](https://github.com/tailscale/tailscale)
|
||||
- [WireGuard](https://www.wireguard.com/)
|
||||
- [Axis Communications](https://www.axis.com/)
|
||||
|
||||
[](https://www.star-history.com/#Mo3he/Axis_Cam_Tailscale&Date)
|
||||
## License
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If you like this project and want to support my work:
|
||||
[Sponsor Me](https://github.com/sponsors/Mo3he)
|
||||
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.
|
||||
@@ -1,8 +1,20 @@
|
||||
BSD 3-Clause License
|
||||
# Third-Party Notices
|
||||
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
All rights reserved.
|
||||
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:
|
||||
|
||||
@@ -13,9 +25,9 @@ modification, are permitted provided that the following conditions are met:
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
@@ -26,4 +38,5 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
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.
|
||||
```
|
||||
+5
-3
@@ -1,13 +1,15 @@
|
||||
ARG ARCH=aarch64
|
||||
ARG VERSION=1.15.1
|
||||
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
|
||||
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,151 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Tailscale VPN run script — called by the param_bridge C binary.
|
||||
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
|
||||
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
|
||||
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
chmod 755 $TAILSCALED_PATH
|
||||
chmod 755 $TAILSCALE_PATH
|
||||
|
||||
# Defaults — overridden by sourcing params.conf written by param_bridge
|
||||
CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
CONF_HTTP="8080"
|
||||
CONF_SOCKS="1080"
|
||||
ACCEPT_DNS="false"
|
||||
ACCEPT_ROUTES="false"
|
||||
ADVERTISE_ROUTES=""
|
||||
|
||||
if [ -f "$STATE_DIR/params.conf" ]; then
|
||||
. "$STATE_DIR/params.conf"
|
||||
fi
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
|
||||
|
||||
# Check whether a TCP port is already bound
|
||||
is_port_in_use() {
|
||||
local port=$1
|
||||
local hex_port
|
||||
hex_port=$(printf '%04X' "$port")
|
||||
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
|
||||
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if is_port_in_use "$CONF_HTTP"; then
|
||||
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
|
||||
exit 1
|
||||
fi
|
||||
if is_port_in_use "$CONF_SOCKS"; then
|
||||
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
--socks5-server=localhost:$CONF_SOCKS \
|
||||
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
|
||||
--tun=userspace-networking \
|
||||
>/dev/null 2>&1 &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
fi
|
||||
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
|
||||
fi
|
||||
|
||||
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
|
||||
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In userspace-networking mode the
|
||||
# tailscaled netstack forwards tailnet traffic to these subnets, so no kernel IP
|
||||
# forwarding is required. Routes must still be approved in the Tailscale admin
|
||||
# console before peers can use them.
|
||||
if [ -n "$ADVERTISE_ROUTES" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
|
||||
fi
|
||||
|
||||
# Run `tailscale up` in the background and act on its outcome. If the node needs
|
||||
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
|
||||
# ensures the status publisher below keeps running so the UI can surface the
|
||||
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
|
||||
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
|
||||
# code is captured directly. We must NOT background `up` separately and `wait`
|
||||
# for it from here, because in POSIX sh `wait` only works on children of the
|
||||
# current shell — a subshell waiting on the parent's child returns 127.
|
||||
{
|
||||
eval "$TAILSCALE_CMD"
|
||||
up_exit=$?
|
||||
if [ "$up_exit" -eq 0 ]; then
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
# 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=$!
|
||||
|
||||
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"
|
||||
|
||||
# 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
Binary file not shown.
Binary file not shown.
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"schemaVersion": "1.7.0",
|
||||
"schemaVersion": "2.0.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"vendor": "moshe@mohome.net",
|
||||
"vendorId": "70ee172dd9",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"version": "1.102.1",
|
||||
"architecture": "aarch64",
|
||||
"runMode": "respawn",
|
||||
"version": "1.98.8",
|
||||
"architecture": "aarch64"
|
||||
"compatibleOsVersions": [
|
||||
{
|
||||
"max": "13"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html",
|
||||
|
||||
@@ -7,7 +7,8 @@ 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_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 ./
|
||||
|
||||
@@ -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,14 +0,0 @@
|
||||
PROG = Tailscale_VPN
|
||||
SRCS = param_bridge.c
|
||||
PKGS = axparameter glib-2.0 gio-2.0
|
||||
CFLAGS += $(shell pkg-config --cflags $(PKGS))
|
||||
LDADD = $(shell pkg-config --libs $(PKGS))
|
||||
|
||||
all: $(PROG)
|
||||
chmod +x Tailscale_VPN_run
|
||||
|
||||
$(PROG): $(SRCS)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
|
||||
|
||||
clean:
|
||||
rm -f $(PROG)
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Tailscale VPN run script (ROOT / kernel networking variant).
|
||||
# Sources config from params.conf written by param_bridge.
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
|
||||
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
|
||||
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
chmod 755 $TAILSCALED_PATH
|
||||
chmod 755 $TAILSCALE_PATH
|
||||
|
||||
CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
ACCEPT_DNS="false"
|
||||
ACCEPT_ROUTES="false"
|
||||
ADVERTISE_ROUTES=""
|
||||
|
||||
if [ -f "$STATE_DIR/params.conf" ]; then
|
||||
. "$STATE_DIR/params.conf"
|
||||
fi
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
|
||||
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
>/dev/null 2>&1 &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
fi
|
||||
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
|
||||
fi
|
||||
|
||||
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
|
||||
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In 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.
|
||||
if [ -n "$ADVERTISE_ROUTES" ]; 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
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
|
||||
fi
|
||||
|
||||
# Run `tailscale up` in the background and act on its outcome. If the node needs
|
||||
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
|
||||
# ensures the status publisher below keeps running so the UI can surface the
|
||||
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
|
||||
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
|
||||
# code is captured directly. We must NOT background `up` separately and `wait`
|
||||
# for it from here, because in POSIX sh `wait` only works on children of the
|
||||
# current shell — a subshell waiting on the parent's child returns 127.
|
||||
{
|
||||
eval "$TAILSCALE_CMD"
|
||||
up_exit=$?
|
||||
if [ "$up_exit" -eq 0 ]; then
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
|
||||
# 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=$!
|
||||
|
||||
# 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
|
||||
Binary file not shown.
Binary file not shown.
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.98.8",
|
||||
"version": "1.102.1",
|
||||
"architecture": "aarch64"
|
||||
},
|
||||
"configuration": {
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
|
||||
* Same structure as regular param_bridge.c but without proxy port params.
|
||||
*/
|
||||
|
||||
#include <axsdk/axparameter.h>
|
||||
#include <glib-unix.h>
|
||||
#include <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"
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
static pid_t child_pid = -1;
|
||||
static guint reload_timer_id = 0;
|
||||
|
||||
static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_accept_dns = NULL;
|
||||
static char *cfg_accept_routes = NULL;
|
||||
static char *cfg_advertise_routes = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
|
||||
* upgrades a newly introduced manifest parameter is not always auto-registered,
|
||||
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
|
||||
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
|
||||
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
|
||||
GError *err = NULL;
|
||||
if (!ax_parameter_add(handle, name, def, "string", &err)) {
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
|
||||
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
|
||||
* disappears from the settings UI. This replaces the old exit-code-0 path,
|
||||
* which never fired because tailscaled keeps the child alive indefinitely. */
|
||||
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (access(SENTINEL_FILE, F_OK) != 0)
|
||||
return G_SOURCE_CONTINUE;
|
||||
|
||||
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
unlink(SENTINEL_FILE);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("AcceptDNS", cfg_accept_dns)
|
||||
LOAD("AcceptRoutes", cfg_accept_routes)
|
||||
LOAD("AdvertiseRoutes", cfg_advertise_routes)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
|
||||
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
|
||||
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: server=%s",
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
|
||||
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
|
||||
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
|
||||
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
|
||||
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
|
||||
* settings through it. This tiny HTTP server, reached through the manifest
|
||||
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
|
||||
* fall back to reading and writing the parameters directly. */
|
||||
|
||||
#define HTTP_PORT 2201
|
||||
|
||||
static const char *http_param_names[] = {
|
||||
"CustomServer", "AuthKey", "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);
|
||||
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, '}');
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting (root mode)");
|
||||
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
|
||||
* freshly configured key before it has been used. */
|
||||
unlink(SENTINEL_FILE);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
ensure_param(handle, "AdvertiseRoutes", "");
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = { "CustomServer", "AuthKey", "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;
|
||||
}
|
||||
+5
-3
@@ -1,13 +1,15 @@
|
||||
ARG ARCH=armv7hf
|
||||
ARG VERSION=1.15.1
|
||||
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
|
||||
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,29 +0,0 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,14 +0,0 @@
|
||||
PROG = Tailscale_VPN
|
||||
SRCS = param_bridge.c
|
||||
PKGS = axparameter glib-2.0 gio-2.0
|
||||
CFLAGS += $(shell pkg-config --cflags $(PKGS))
|
||||
LDADD = $(shell pkg-config --libs $(PKGS))
|
||||
|
||||
all: $(PROG)
|
||||
chmod +x Tailscale_VPN_run
|
||||
|
||||
$(PROG): $(SRCS)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
|
||||
|
||||
clean:
|
||||
rm -f $(PROG)
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Tailscale VPN run script — called by the param_bridge C binary.
|
||||
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
|
||||
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
|
||||
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
chmod 755 $TAILSCALED_PATH
|
||||
chmod 755 $TAILSCALE_PATH
|
||||
|
||||
# Defaults — overridden by sourcing params.conf written by param_bridge
|
||||
CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
CONF_HTTP="8080"
|
||||
CONF_SOCKS="1080"
|
||||
ACCEPT_DNS="false"
|
||||
ACCEPT_ROUTES="false"
|
||||
ADVERTISE_ROUTES=""
|
||||
|
||||
if [ -f "$STATE_DIR/params.conf" ]; then
|
||||
. "$STATE_DIR/params.conf"
|
||||
fi
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
|
||||
|
||||
# Check whether a TCP port is already bound
|
||||
is_port_in_use() {
|
||||
local port=$1
|
||||
local hex_port
|
||||
hex_port=$(printf '%04X' "$port")
|
||||
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
|
||||
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if is_port_in_use "$CONF_HTTP"; then
|
||||
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
|
||||
exit 1
|
||||
fi
|
||||
if is_port_in_use "$CONF_SOCKS"; then
|
||||
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
--socks5-server=localhost:$CONF_SOCKS \
|
||||
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
|
||||
--tun=userspace-networking \
|
||||
>/dev/null 2>&1 &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
fi
|
||||
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
|
||||
fi
|
||||
|
||||
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
|
||||
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In userspace-networking mode the
|
||||
# tailscaled netstack forwards tailnet traffic to these subnets, so no kernel IP
|
||||
# forwarding is required. Routes must still be approved in the Tailscale admin
|
||||
# console before peers can use them.
|
||||
if [ -n "$ADVERTISE_ROUTES" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
|
||||
fi
|
||||
|
||||
# Run `tailscale up` in the background and act on its outcome. If the node needs
|
||||
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
|
||||
# ensures the status publisher below keeps running so the UI can surface the
|
||||
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
|
||||
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
|
||||
# code is captured directly. We must NOT background `up` separately and `wait`
|
||||
# for it from here, because in POSIX sh `wait` only works on children of the
|
||||
# current shell — a subshell waiting on the parent's child returns 127.
|
||||
{
|
||||
eval "$TAILSCALE_CMD"
|
||||
up_exit=$?
|
||||
if [ "$up_exit" -eq 0 ]; then
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
# 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=$!
|
||||
|
||||
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"
|
||||
|
||||
# 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
Binary file not shown.
Binary file not shown.
+10
-5
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"schemaVersion": "1.7.0",
|
||||
"schemaVersion": "2.0.0",
|
||||
"acapPackageConf": {
|
||||
"setup": {
|
||||
"appName": "Tailscale_VPN",
|
||||
"friendlyName": "Tailscale VPN",
|
||||
"vendor": "Mo3he",
|
||||
"embeddedSdkVersion": "3.0",
|
||||
"vendor": "moshe@mohome.net",
|
||||
"vendorId": "70ee172dd9",
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"version": "1.102.1",
|
||||
"architecture": "armv7hf",
|
||||
"runMode": "respawn",
|
||||
"version": "1.98.8",
|
||||
"architecture": "armv7hf"
|
||||
"compatibleOsVersions": [
|
||||
{
|
||||
"max": "13"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"settingPage": "index.html",
|
||||
|
||||
@@ -1,549 +0,0 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (userspace variant).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
|
||||
* 2. Write them to CONFIG_FILE so the shell script can source them.
|
||||
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
|
||||
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
|
||||
* of the child so the new config is picked up.
|
||||
* Rapid changes within 300 ms are coalesced into a single restart.
|
||||
* 5. Watchdog: if the child exits unexpectedly, restart it.
|
||||
*
|
||||
* Runs as the unprivileged 'sdk' ACAP user — no root required.
|
||||
*/
|
||||
|
||||
#include <axsdk/axparameter.h>
|
||||
#include <glib-unix.h>
|
||||
#include <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"
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
static pid_t child_pid = -1;
|
||||
static guint reload_timer_id = 0;
|
||||
|
||||
static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_http_proxy_port = NULL;
|
||||
static char *cfg_socks5_port = NULL;
|
||||
static char *cfg_accept_dns = NULL;
|
||||
static char *cfg_accept_routes = NULL;
|
||||
static char *cfg_advertise_routes = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
|
||||
* upgrades a newly introduced manifest parameter is not always auto-registered,
|
||||
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
|
||||
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
|
||||
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
|
||||
GError *err = NULL;
|
||||
if (!ax_parameter_add(handle, name, def, "string", &err)) {
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── child process management ──────────────────────────────────────────── */
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
/* ── watchdog ────────────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── auth-key sentinel ───────────────────────────────────────────────────── */
|
||||
|
||||
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
|
||||
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
|
||||
* disappears from the settings UI. This replaces the old exit-code-0 path,
|
||||
* which never fired because tailscaled keeps the child alive indefinitely. */
|
||||
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (access(SENTINEL_FILE, F_OK) != 0)
|
||||
return G_SOURCE_CONTINUE;
|
||||
|
||||
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
unlink(SENTINEL_FILE);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* ── config file ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("HttpProxyPort", cfg_http_proxy_port)
|
||||
LOAD("Socks5Port", cfg_socks5_port)
|
||||
LOAD("AcceptDNS", cfg_accept_dns)
|
||||
LOAD("AcceptRoutes", cfg_accept_routes)
|
||||
LOAD("AdvertiseRoutes", cfg_advertise_routes)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
||||
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
|
||||
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
|
||||
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
||||
cache_get(&cfg_http_proxy_port, "8080"),
|
||||
cache_get(&cfg_socks5_port, "1080"),
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "HttpProxyPort") == 0) cache_set(&cfg_http_proxy_port, value);
|
||||
else if (strcmp(short_name, "Socks5Port") == 0) cache_set(&cfg_socks5_port, value);
|
||||
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
|
||||
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
|
||||
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
|
||||
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
|
||||
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
|
||||
* settings through it. This tiny HTTP server, reached through the manifest
|
||||
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
|
||||
* fall back to reading and writing the parameters directly. */
|
||||
|
||||
#define HTTP_PORT 2201
|
||||
|
||||
static const char *http_param_names[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
|
||||
"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);
|
||||
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);
|
||||
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, '}');
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
/* Apply an application/x-www-form-urlencoded body of shortName=value pairs to
|
||||
* the parameter store. Returns the number of parameters successfully set. */
|
||||
static int http_apply_settings(AXParameter *handle, const char *body, size_t len) {
|
||||
int applied = 0;
|
||||
size_t start = 0;
|
||||
for (size_t i = 0; i <= len; i++) {
|
||||
if (i == len || body[i] == '&') {
|
||||
size_t seg_len = i - start;
|
||||
if (seg_len > 0) {
|
||||
const char *seg = body + start;
|
||||
const char *eq = memchr(seg, '=', seg_len);
|
||||
if (eq) {
|
||||
size_t nlen = (size_t)(eq - seg);
|
||||
gchar *name = g_strndup(seg, nlen);
|
||||
gchar *value = http_url_decode(eq + 1, seg_len - nlen - 1);
|
||||
if (http_is_known_param(name)) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(handle, name, value, TRUE, &err)) {
|
||||
cache_set_by_name(name, value);
|
||||
applied++;
|
||||
} else {
|
||||
syslog(LOG_WARNING, "http set %s failed: %s",
|
||||
name, err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
g_free(name);
|
||||
g_free(value);
|
||||
}
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
static size_t http_parse_content_length(const char *hdr, size_t hlen) {
|
||||
const char *key = "content-length:";
|
||||
size_t klen = strlen(key);
|
||||
for (size_t i = 0; i + klen <= hlen; i++) {
|
||||
if (g_ascii_strncasecmp(hdr + i, key, klen) == 0) {
|
||||
i += klen;
|
||||
while (i < hlen && (hdr[i] == ' ' || hdr[i] == '\t')) i++;
|
||||
return (size_t)strtoul(hdr + i, NULL, 10);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void http_send(GOutputStream *out, const char *status,
|
||||
const char *ctype, const char *body) {
|
||||
gchar *resp = g_strdup_printf(
|
||||
"HTTP/1.1 %s\r\n"
|
||||
"Content-Type: %s\r\n"
|
||||
"Content-Length: %zu\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n"
|
||||
"%s",
|
||||
status, ctype, strlen(body), body);
|
||||
g_output_stream_write_all(out, resp, strlen(resp), NULL, NULL, NULL);
|
||||
g_free(resp);
|
||||
}
|
||||
|
||||
static gboolean http_on_incoming(GSocketService *service G_GNUC_UNUSED,
|
||||
GSocketConnection *connection,
|
||||
GObject *source G_GNUC_UNUSED,
|
||||
gpointer user_data) {
|
||||
AXParameter *handle = (AXParameter *)user_data;
|
||||
GInputStream *in = g_io_stream_get_input_stream(G_IO_STREAM(connection));
|
||||
GOutputStream *out = g_io_stream_get_output_stream(G_IO_STREAM(connection));
|
||||
|
||||
GString *req = g_string_new(NULL);
|
||||
char buf[2048];
|
||||
int have_headers = 0;
|
||||
size_t header_end = 0;
|
||||
size_t content_length = 0;
|
||||
|
||||
while (1) {
|
||||
gssize n = g_input_stream_read(in, buf, sizeof(buf), NULL, NULL);
|
||||
if (n <= 0) break;
|
||||
g_string_append_len(req, buf, n);
|
||||
if (!have_headers) {
|
||||
char *p = g_strstr_len(req->str, req->len, "\r\n\r\n");
|
||||
if (p) {
|
||||
have_headers = 1;
|
||||
header_end = (size_t)(p - req->str) + 4;
|
||||
content_length = http_parse_content_length(req->str, header_end);
|
||||
}
|
||||
}
|
||||
if (have_headers && req->len - header_end >= content_length) break;
|
||||
if (req->len > 262144) break; /* safety cap */
|
||||
}
|
||||
|
||||
int is_get = 0, is_post = 0, is_settings = 0;
|
||||
if (have_headers) {
|
||||
if (g_str_has_prefix(req->str, "GET ")) is_get = 1;
|
||||
if (g_str_has_prefix(req->str, "POST ")) is_post = 1;
|
||||
const char *sp1 = strchr(req->str, ' ');
|
||||
if (sp1) {
|
||||
const char *path = sp1 + 1;
|
||||
const char *sp2 = strchr(path, ' ');
|
||||
size_t plen = sp2 ? (size_t)(sp2 - path) : strlen(path);
|
||||
const char *q = memchr(path, '?', plen);
|
||||
size_t match_len = q ? (size_t)(q - path) : plen;
|
||||
if (match_len >= 8 &&
|
||||
g_ascii_strncasecmp(path + match_len - 8, "settings", 8) == 0)
|
||||
is_settings = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_settings && is_get) {
|
||||
gchar *json = http_build_settings_json(handle);
|
||||
http_send(out, "200 OK", "application/json", json);
|
||||
g_free(json);
|
||||
} else if (is_settings && is_post) {
|
||||
const char *body = req->str + header_end;
|
||||
size_t body_len = req->len - header_end;
|
||||
if (body_len > content_length) body_len = content_length;
|
||||
int applied = http_apply_settings(handle, body, body_len);
|
||||
syslog(LOG_INFO, "settings http: applied %d parameter(s)", applied);
|
||||
if (reload_timer_id) g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
http_send(out, "200 OK", "text/plain", "OK");
|
||||
} else {
|
||||
http_send(out, "404 Not Found", "text/plain", "Not found");
|
||||
}
|
||||
|
||||
g_string_free(req, TRUE);
|
||||
g_io_stream_close(G_IO_STREAM(connection), NULL, NULL);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void http_server_start(AXParameter *handle) {
|
||||
GError *err = NULL;
|
||||
GSocketService *service = g_socket_service_new();
|
||||
GInetAddress *addr = g_inet_address_new_from_string("127.0.0.1");
|
||||
GSocketAddress *saddr = g_inet_socket_address_new(addr, HTTP_PORT);
|
||||
|
||||
if (!g_socket_listener_add_address(G_SOCKET_LISTENER(service), saddr,
|
||||
G_SOCKET_TYPE_STREAM, G_SOCKET_PROTOCOL_TCP,
|
||||
NULL, NULL, &err)) {
|
||||
syslog(LOG_WARNING, "settings http: bind 127.0.0.1:%d failed: %s",
|
||||
HTTP_PORT, err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
g_object_unref(service);
|
||||
} else {
|
||||
g_signal_connect(service, "incoming", G_CALLBACK(http_on_incoming), handle);
|
||||
g_socket_service_start(service);
|
||||
syslog(LOG_INFO, "settings http server listening on 127.0.0.1:%d", HTTP_PORT);
|
||||
}
|
||||
g_object_unref(addr);
|
||||
g_object_unref(saddr);
|
||||
}
|
||||
|
||||
/* ── signal handler ──────────────────────────────────────────────────────── */
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ── main ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting");
|
||||
|
||||
/* Ensure localdata dir exists */
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
|
||||
* freshly configured key before it has been used. */
|
||||
unlink(SENTINEL_FILE);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
ensure_param(handle, "AdvertiseRoutes", "");
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
|
||||
"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;
|
||||
}
|
||||
+2
-1
@@ -7,7 +7,8 @@ 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_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 ./
|
||||
|
||||
+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,29 +0,0 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,14 +0,0 @@
|
||||
PROG = Tailscale_VPN
|
||||
SRCS = param_bridge.c
|
||||
PKGS = axparameter glib-2.0 gio-2.0
|
||||
CFLAGS += $(shell pkg-config --cflags $(PKGS))
|
||||
LDADD = $(shell pkg-config --libs $(PKGS))
|
||||
|
||||
all: $(PROG)
|
||||
chmod +x Tailscale_VPN_run
|
||||
|
||||
$(PROG): $(SRCS)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
|
||||
|
||||
clean:
|
||||
rm -f $(PROG)
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Tailscale VPN run script (ROOT / kernel networking variant).
|
||||
# Sources config from params.conf written by param_bridge.
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
|
||||
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
|
||||
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
chmod 755 $TAILSCALED_PATH
|
||||
chmod 755 $TAILSCALE_PATH
|
||||
|
||||
CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
ACCEPT_DNS="false"
|
||||
ACCEPT_ROUTES="false"
|
||||
ADVERTISE_ROUTES=""
|
||||
|
||||
if [ -f "$STATE_DIR/params.conf" ]; then
|
||||
. "$STATE_DIR/params.conf"
|
||||
fi
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
|
||||
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
>/dev/null 2>&1 &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
fi
|
||||
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
|
||||
fi
|
||||
|
||||
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
|
||||
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In 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.
|
||||
if [ -n "$ADVERTISE_ROUTES" ]; 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
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
|
||||
fi
|
||||
|
||||
# Run `tailscale up` in the background and act on its outcome. If the node needs
|
||||
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
|
||||
# ensures the status publisher below keeps running so the UI can surface the
|
||||
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
|
||||
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
|
||||
# code is captured directly. We must NOT background `up` separately and `wait`
|
||||
# for it from here, because in POSIX sh `wait` only works on children of the
|
||||
# current shell — a subshell waiting on the parent's child returns 127.
|
||||
{
|
||||
eval "$TAILSCALE_CMD"
|
||||
up_exit=$?
|
||||
if [ "$up_exit" -eq 0 ]; then
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
|
||||
# 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=$!
|
||||
|
||||
# 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
Binary file not shown.
Binary file not shown.
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
|
||||
"runMode": "respawn",
|
||||
"version": "1.98.8",
|
||||
"version": "1.102.1",
|
||||
"architecture": "armv7hf"
|
||||
},
|
||||
"configuration": {
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
// Copyright (C) 2024 Mo3he
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (ROOT / kernel networking variant).
|
||||
* Same structure as regular param_bridge.c but without proxy port params.
|
||||
*/
|
||||
|
||||
#include <axsdk/axparameter.h>
|
||||
#include <glib-unix.h>
|
||||
#include <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"
|
||||
|
||||
static AXParameter *g_ax_handle = NULL;
|
||||
static pid_t child_pid = -1;
|
||||
static guint reload_timer_id = 0;
|
||||
|
||||
static char *cfg_custom_server = NULL;
|
||||
static char *cfg_auth_key = NULL;
|
||||
static char *cfg_accept_dns = NULL;
|
||||
static char *cfg_accept_routes = NULL;
|
||||
static char *cfg_advertise_routes = NULL;
|
||||
|
||||
static void cache_set(char **field, const char *value) {
|
||||
if (!value) return;
|
||||
free(*field);
|
||||
*field = strdup(value);
|
||||
}
|
||||
|
||||
static const char *cache_get(char **field, const char *fallback) {
|
||||
return (*field && **field) ? *field : fallback;
|
||||
}
|
||||
|
||||
/* Ensure a parameter exists in the device parameter database. On in-place ACAP
|
||||
* upgrades a newly introduced manifest parameter is not always auto-registered,
|
||||
* which makes param.cgi return a 404 when the web UI tries to set it. Creating
|
||||
* it here is idempotent: if it already exists, ax_parameter_add fails harmlessly. */
|
||||
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
|
||||
GError *err = NULL;
|
||||
if (!ax_parameter_add(handle, name, def, "string", &err)) {
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
|
||||
static void stop_child(void) {
|
||||
if (child_pid <= 0)
|
||||
return;
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int status;
|
||||
if (waitpid(child_pid, &status, WNOHANG) == child_pid) {
|
||||
child_pid = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
syslog(LOG_WARNING, "child did not exit in 3 s, sending SIGKILL");
|
||||
kill(child_pid, SIGKILL);
|
||||
waitpid(child_pid, NULL, 0);
|
||||
child_pid = -1;
|
||||
}
|
||||
|
||||
static void start_child(void) {
|
||||
stop_child();
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
syslog(LOG_ERR, "fork failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
child_pid = pid;
|
||||
syslog(LOG_INFO, "started %s (pid %d)", RUN_SCRIPT, child_pid);
|
||||
}
|
||||
|
||||
static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (child_pid > 0) {
|
||||
int status;
|
||||
pid_t ret = waitpid(child_pid, &status, WNOHANG);
|
||||
if (ret == child_pid) {
|
||||
int exit_code = WEXITSTATUS(status);
|
||||
syslog(LOG_WARNING, "child exited (status %d), restarting", exit_code);
|
||||
child_pid = -1;
|
||||
/* If child exited 0, auth succeeded — clear AuthKey via axparameter */
|
||||
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
start_child();
|
||||
}
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/* The run script drops SENTINEL_FILE after a successful `tailscale up` that
|
||||
* used a one-time auth key. Clear the stored AuthKey so it is not reused and
|
||||
* disappears from the settings UI. This replaces the old exit-code-0 path,
|
||||
* which never fired because tailscaled keeps the child alive indefinitely. */
|
||||
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
|
||||
if (access(SENTINEL_FILE, F_OK) != 0)
|
||||
return G_SOURCE_CONTINUE;
|
||||
|
||||
if (g_ax_handle && cfg_auth_key && *cfg_auth_key) {
|
||||
GError *err = NULL;
|
||||
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
|
||||
free(cfg_auth_key); cfg_auth_key = strdup("");
|
||||
syslog(LOG_INFO, "AuthKey cleared after successful auth (sentinel)");
|
||||
} else {
|
||||
syslog(LOG_WARNING, "failed to clear AuthKey: %s",
|
||||
err ? err->message : "unknown");
|
||||
if (err) g_error_free(err);
|
||||
}
|
||||
}
|
||||
unlink(SENTINEL_FILE);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static void load_config_cache(AXParameter *handle) {
|
||||
GError *error = NULL;
|
||||
gchar *val = NULL;
|
||||
|
||||
#define LOAD(name, field) \
|
||||
val = NULL; error = NULL; \
|
||||
if (ax_parameter_get(handle, name, &val, &error)) { \
|
||||
free(field); field = val ? strdup(val) : strdup(""); \
|
||||
g_free(val); val = NULL; \
|
||||
} else { \
|
||||
syslog(LOG_WARNING, "ax_parameter_get %s failed: %s", name, \
|
||||
error ? error->message : "unknown"); \
|
||||
if (error) { g_error_free(error); error = NULL; } \
|
||||
}
|
||||
|
||||
LOAD("CustomServer", cfg_custom_server)
|
||||
LOAD("AuthKey", cfg_auth_key)
|
||||
LOAD("AcceptDNS", cfg_accept_dns)
|
||||
LOAD("AcceptRoutes", cfg_accept_routes)
|
||||
LOAD("AdvertiseRoutes", cfg_advertise_routes)
|
||||
#undef LOAD
|
||||
}
|
||||
|
||||
static void write_config_file(void) {
|
||||
FILE *f = fopen(CONFIG_FILE, "w");
|
||||
if (!f) {
|
||||
syslog(LOG_ERR, "cannot open config file %s: %s",
|
||||
CONFIG_FILE, strerror(errno));
|
||||
return;
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
|
||||
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
|
||||
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
syslog(LOG_INFO, "config updated: server=%s",
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
}
|
||||
|
||||
static gboolean debounced_restart(gpointer G_GNUC_UNUSED data) {
|
||||
reload_timer_id = 0;
|
||||
if (g_ax_handle)
|
||||
load_config_cache(g_ax_handle);
|
||||
write_config_file();
|
||||
syslog(LOG_INFO, "restarting with new config");
|
||||
stop_child();
|
||||
start_child();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void parameter_changed(const gchar *name, const gchar *value,
|
||||
gpointer G_GNUC_UNUSED handle_void_ptr) {
|
||||
const char *dot = strrchr(name, '.');
|
||||
const char *short_name = dot ? dot + 1 : name;
|
||||
syslog(LOG_INFO, "parameter changed: %s", short_name);
|
||||
|
||||
if (strcmp(short_name, "CustomServer") == 0) cache_set(&cfg_custom_server, value);
|
||||
else if (strcmp(short_name, "AuthKey") == 0) cache_set(&cfg_auth_key, value);
|
||||
else if (strcmp(short_name, "AcceptDNS") == 0) cache_set(&cfg_accept_dns, value);
|
||||
else if (strcmp(short_name, "AcceptRoutes") == 0) cache_set(&cfg_accept_routes, value);
|
||||
else if (strcmp(short_name, "AdvertiseRoutes") == 0) cache_set(&cfg_advertise_routes, value);
|
||||
|
||||
if (reload_timer_id)
|
||||
g_source_remove(reload_timer_id);
|
||||
reload_timer_id = g_timeout_add(300, debounced_restart, NULL);
|
||||
}
|
||||
|
||||
/* ── embedded settings HTTP server (reverse-proxy fallback) ──────────────────
|
||||
* Some AXIS device classes (e.g. recorders/NVRs) do not expose the legacy
|
||||
* /axis-cgi/param.cgi VAPIX endpoint, so the web UI cannot load or save
|
||||
* settings through it. This tiny HTTP server, reached through the manifest
|
||||
* reverseProxy mapping at /local/Tailscale_VPN/api/settings, lets the web UI
|
||||
* fall back to reading and writing the parameters directly. */
|
||||
|
||||
#define HTTP_PORT 2201
|
||||
|
||||
static const char *http_param_names[] = {
|
||||
"CustomServer", "AuthKey", "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);
|
||||
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, '}');
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
static gboolean signal_handler(gpointer loop) {
|
||||
syslog(LOG_INFO, "stopping");
|
||||
stop_child();
|
||||
g_main_loop_quit((GMainLoop *)loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
GError *error = NULL;
|
||||
|
||||
openlog(APP_NAME, LOG_PID, LOG_USER);
|
||||
syslog(LOG_INFO, "starting (root mode)");
|
||||
|
||||
mkdir("/usr/local/packages/Tailscale_VPN/localdata", 0755);
|
||||
|
||||
/* Drop any stale auth-key sentinel from a previous run so we don't clear a
|
||||
* freshly configured key before it has been used. */
|
||||
unlink(SENTINEL_FILE);
|
||||
|
||||
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
|
||||
if (!handle) {
|
||||
syslog(LOG_ERR, "ax_parameter_new: %s",
|
||||
error ? error->message : "unknown");
|
||||
if (error) g_error_free(error);
|
||||
return 1;
|
||||
}
|
||||
g_ax_handle = handle;
|
||||
|
||||
ensure_param(handle, "AdvertiseRoutes", "");
|
||||
|
||||
load_config_cache(handle);
|
||||
write_config_file();
|
||||
start_child();
|
||||
|
||||
const char *params[] = { "CustomServer", "AuthKey", "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;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ FROM axisecp/acap-sdk:3.5-armv7hf-ubuntu${UBUNTU_VERSION}
|
||||
RUN apt-get update -qq && apt-get install -y --no-install-recommends upx-ucl && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY ./app /opt/app/
|
||||
COPY arm_acap3/app /opt/app/
|
||||
WORKDIR /opt/app
|
||||
|
||||
# Rename the shell startup script (the ELF launcher will take the Tailscale_VPN name)
|
||||
|
||||
+23
-23
@@ -16,19 +16,19 @@ logger -t "Tailscale_VPN" "Starting tailscaled daemon"
|
||||
# Log to file (not piped through logger) -- avoids extra logger process holding
|
||||
# tailscaled stdout open, which prevents our wait loop from detecting exit
|
||||
"$APP_DIR/lib/tailscaled" \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
--socks5-server=localhost:1055 \
|
||||
--outbound-http-proxy-listen=localhost:8080 \
|
||||
--tun=userspace-networking \
|
||||
>> "$STATE_DIR/tailscaled.log" 2>&1 &
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
--socks5-server=localhost:1055 \
|
||||
--outbound-http-proxy-listen=localhost:8080 \
|
||||
--tun=userspace-networking \
|
||||
>>"$STATE_DIR/tailscaled.log" 2>&1 &
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
# Wait for socket to appear (up to 15 seconds)
|
||||
i=0
|
||||
while [ $i -lt 15 ] && [ ! -S "$STATE_DIR/tailscaled.sock" ]; do
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
logger -t "Tailscale_VPN" "Connecting to Tailscale network"
|
||||
@@ -37,9 +37,9 @@ logger -t "Tailscale_VPN" "Connecting to Tailscale network"
|
||||
# cameras with limited RAM (e.g. 222 MB).
|
||||
# Capture output so we can extract auth URL and log it to syslog for the web UI.
|
||||
UP_OUT=$("$APP_DIR/lib/tailscale" \
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
up --hostname="$(hostname)" --timeout=10s 2>&1) || true
|
||||
echo "$UP_OUT" >> "$STATE_DIR/tailscaled.log"
|
||||
--socket="$STATE_DIR/tailscaled.sock" \
|
||||
up --hostname="$(hostname)" --timeout=10s 2>&1) || true
|
||||
echo "$UP_OUT" >>"$STATE_DIR/tailscaled.log"
|
||||
|
||||
# If an auth URL was returned, log it so the web UI can show it
|
||||
AUTH_URL=$(echo "$UP_OUT" | grep -o 'https://login\.tailscale\.com/[^ ]*' | head -1)
|
||||
@@ -48,7 +48,7 @@ AUTH_URL=$(echo "$UP_OUT" | grep -o 'https://login\.tailscale\.com/[^ ]*' | head
|
||||
# Log IP and version into syslog so the web UI details panel can populate
|
||||
TS_IP=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" ip -4 2>/dev/null | head -1)
|
||||
TS_VER=$("$APP_DIR/lib/tailscale" --socket="$STATE_DIR/tailscaled.sock" version 2>/dev/null | head -1)
|
||||
[ -n "$TS_IP" ] && logger -t "Tailscale_VPN" "Tailscale IP: $TS_IP"
|
||||
[ -n "$TS_IP" ] && logger -t "Tailscale_VPN" "Tailscale IP: $TS_IP"
|
||||
[ -n "$TS_VER" ] && logger -t "Tailscale_VPN" "Tailscale version: $TS_VER"
|
||||
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
@@ -63,19 +63,19 @@ logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:1055"
|
||||
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
|
||||
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
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
@@ -83,8 +83,8 @@ trap cleanup TERM INT
|
||||
# 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
|
||||
publish_status
|
||||
sleep 5
|
||||
done
|
||||
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
|
||||
@@ -2,8 +2,8 @@ PACKAGENAME=Tailscale_VPN
|
||||
MENUNAME="Tailscale VPN"
|
||||
VENDOR="Mo3he"
|
||||
APPMAJORVERSION=1
|
||||
APPMINORVERSION=98
|
||||
APPMICROVERSION=8
|
||||
APPMINORVERSION=102
|
||||
APPMICROVERSION=1
|
||||
APPTYPE=armv7hf
|
||||
APPNAME=Tailscale_VPN
|
||||
APPOPTS=""
|
||||
|
||||
@@ -2,6 +2,7 @@ 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)
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/bin/sh
|
||||
# Tailscale VPN run script — called by the param_bridge C binary.
|
||||
# Config is sourced from $STATE_DIR/params.conf (written by param_bridge).
|
||||
# $1 selects the variant: "standard" (userspace networking + local proxies)
|
||||
# or "root" (kernel networking, no local proxy). Defaults to "standard".
|
||||
VARIANT="${1:-standard}"
|
||||
|
||||
killall tailscaled 2>/dev/null || true
|
||||
|
||||
APP_DIR="/usr/local/packages/Tailscale_VPN"
|
||||
STATE_DIR="$APP_DIR/localdata"
|
||||
TAILSCALED_PATH="$APP_DIR/lib/tailscaled"
|
||||
TAILSCALE_PATH="$APP_DIR/lib/tailscale"
|
||||
SOCKET_PATH="$STATE_DIR/tailscaled.sock"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
chmod 755 $TAILSCALED_PATH
|
||||
chmod 755 $TAILSCALE_PATH
|
||||
|
||||
# Defaults — overridden by sourcing params.conf written by param_bridge
|
||||
CUSTOM_SERVER=""
|
||||
AUTH_KEY=""
|
||||
CONF_HTTP="8080"
|
||||
CONF_SOCKS="1080"
|
||||
ACCEPT_DNS="false"
|
||||
ACCEPT_ROUTES="false"
|
||||
ADVERTISE_ROUTES=""
|
||||
|
||||
if [ -f "$STATE_DIR/params.conf" ]; then
|
||||
. "$STATE_DIR/params.conf"
|
||||
fi
|
||||
|
||||
if [ "$VARIANT" = "root" ]; then
|
||||
logger -t "Tailscale_VPN" "Starting (root mode): custom_server=${CUSTOM_SERVER:-(default)}"
|
||||
else
|
||||
logger -t "Tailscale_VPN" "Starting: http_port=$CONF_HTTP socks_port=$CONF_SOCKS custom_server=${CUSTOM_SERVER:-(default)}"
|
||||
|
||||
# Check whether a TCP port is already bound
|
||||
is_port_in_use() {
|
||||
local port=$1
|
||||
local hex_port
|
||||
hex_port=$(printf '%04X' "$port")
|
||||
grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null && return 0
|
||||
grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if is_port_in_use "$CONF_HTTP"; then
|
||||
logger -t "Tailscale_VPN" "ERROR: HTTP proxy port $CONF_HTTP is already in use. Change it in Settings."
|
||||
exit 1
|
||||
fi
|
||||
if is_port_in_use "$CONF_SOCKS"; then
|
||||
logger -t "Tailscale_VPN" "ERROR: SOCKS5 port $CONF_SOCKS is already in use. Change it in Settings."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
logger -t "Tailscale_VPN" "Starting tailscaled daemon"
|
||||
if [ "$VARIANT" = "root" ]; then
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
>/dev/null 2>&1 &
|
||||
else
|
||||
$TAILSCALED_PATH \
|
||||
--state="$STATE_DIR/tailscaled.state" \
|
||||
--socket=$SOCKET_PATH \
|
||||
--socks5-server=localhost:$CONF_SOCKS \
|
||||
--outbound-http-proxy-listen=localhost:$CONF_HTTP \
|
||||
--tun=userspace-networking \
|
||||
>/dev/null 2>&1 &
|
||||
fi
|
||||
TAILSCALED_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
|
||||
|
||||
if [ -n "$CUSTOM_SERVER" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
|
||||
fi
|
||||
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_DNS" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
|
||||
fi
|
||||
|
||||
if [ "$ACCEPT_ROUTES" = "true" ]; then
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
|
||||
fi
|
||||
|
||||
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
|
||||
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In userspace-networking mode the
|
||||
# tailscaled netstack forwards tailnet traffic to these subnets, so no kernel IP
|
||||
# forwarding is required. In kernel-networking (root) mode the host must
|
||||
# forward packets between the tailnet and the LAN, so enable IP forwarding.
|
||||
# Routes must still be approved in the Tailscale admin console either way.
|
||||
if [ -n "$ADVERTISE_ROUTES" ]; then
|
||||
if [ "$VARIANT" = "root" ]; then
|
||||
echo 1 >/proc/sys/net/ipv4/ip_forward 2>/dev/null || true
|
||||
echo 1 >/proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
|
||||
fi
|
||||
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
|
||||
fi
|
||||
|
||||
# Run `tailscale up` in the background and act on its outcome. If the node needs
|
||||
# (re-)authentication, `up` blocks until the user logs in; backgrounding it
|
||||
# ensures the status publisher below keeps running so the UI can surface the
|
||||
# login URL (tailscaled reports BackendState=NeedsLogin + AuthURL while waiting).
|
||||
# NOTE: `up` runs synchronously *inside* this backgrounded block so its real exit
|
||||
# code is captured directly. We must NOT background `up` separately and `wait`
|
||||
# for it from here, because in POSIX sh `wait` only works on children of the
|
||||
# current shell — a subshell waiting on the parent's child returns 127.
|
||||
{
|
||||
eval "$TAILSCALE_CMD"
|
||||
up_exit=$?
|
||||
if [ "$up_exit" -eq 0 ]; then
|
||||
if [ "$VARIANT" = "root" ]; then
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running (root mode)"
|
||||
else
|
||||
logger -t "Tailscale_VPN" "Tailscale VPN is running"
|
||||
fi
|
||||
# Auth succeeded with a one-time auth key — signal param_bridge to clear it
|
||||
if [ -n "$AUTH_KEY" ]; then
|
||||
: >"$STATE_DIR/authkey_clear"
|
||||
fi
|
||||
else
|
||||
logger -t "Tailscale_VPN" "ERROR: tailscale up failed (exit $up_exit)"
|
||||
fi
|
||||
} &
|
||||
TAILSCALE_UP_PID=$!
|
||||
|
||||
if [ "$VARIANT" != "root" ]; then
|
||||
logger -t "Tailscale_VPN" "HTTP/HTTPS proxy: http://127.0.0.1:$CONF_HTTP"
|
||||
logger -t "Tailscale_VPN" "SOCKS5 proxy: 127.0.0.1:$CONF_SOCKS"
|
||||
fi
|
||||
|
||||
# Publish tailscale's real backend state as JSON for the web UI to consume.
|
||||
# This is the authoritative connection signal (BackendState / Self.Online /
|
||||
# TailscaleIPs / AuthURL) instead of scraping syslog. Served statically at
|
||||
# /local/Tailscale_VPN/status.json.
|
||||
STATUS_FILE="$APP_DIR/html/status.json"
|
||||
|
||||
publish_status() {
|
||||
if "$TAILSCALE_PATH" --socket="$SOCKET_PATH" status --json >"$STATUS_FILE.tmp" 2>/dev/null; then
|
||||
mv "$STATUS_FILE.tmp" "$STATUS_FILE" 2>/dev/null
|
||||
chmod 644 "$STATUS_FILE" 2>/dev/null
|
||||
else
|
||||
rm -f "$STATUS_FILE.tmp" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
status_loop() {
|
||||
while true; do
|
||||
publish_status
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
status_loop &
|
||||
STATUS_LOOP_PID=$!
|
||||
|
||||
# Clean up the status writer, up watcher, daemon and published status on
|
||||
# stop/restart so param_bridge (which signals this script) leaves no orphans or
|
||||
# stale state.
|
||||
cleanup() {
|
||||
[ -n "$STATUS_LOOP_PID" ] && kill "$STATUS_LOOP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALE_UP_PID" ] && kill "$TAILSCALE_UP_PID" 2>/dev/null
|
||||
[ -n "$TAILSCALED_PID" ] && kill "$TAILSCALED_PID" 2>/dev/null
|
||||
rm -f "$STATUS_FILE" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait $TAILSCALED_PID
|
||||
@@ -403,8 +403,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Info (always visible) -->
|
||||
<div class="card">
|
||||
<!-- Proxy Info (hidden on ROOT builds, which have no local proxy) -->
|
||||
<div class="card" id="proxy-info-card" style="display:none;">
|
||||
<div class="card-title">Proxy Configuration</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
@@ -432,12 +432,12 @@
|
||||
<input class="settings-input" id="input-authkey" type="text" autocomplete="off" placeholder="tskey-auth-... (leave blank to use browser login)">
|
||||
<span class="settings-hint">One-time use. Cleared automatically after first successful connection.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-row" id="http-port-row" style="display:none;">
|
||||
<label class="settings-label" for="input-http-port">HTTP Proxy Port</label>
|
||||
<input class="settings-input" id="input-http-port" type="text" autocomplete="off" placeholder="8080">
|
||||
<span class="settings-hint">Port for the outbound HTTP/HTTPS proxy. Default: 8080.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-row" id="socks-port-row" style="display:none;">
|
||||
<label class="settings-label" for="input-socks-port">SOCKS5 Proxy Port</label>
|
||||
<input class="settings-input" id="input-socks-port" type="text" autocomplete="off" placeholder="1080">
|
||||
<span class="settings-hint">Port for the SOCKS5 proxy. Default: 1080.</span>
|
||||
@@ -886,6 +886,22 @@
|
||||
// exposed through the manifest reverseProxy mapping at API_URL.
|
||||
var API_URL = '/local/' + APP + '/api/settings';
|
||||
|
||||
// Whether this build exposes local HTTP/SOCKS5 proxies (absent on ROOT
|
||||
// builds, which use kernel networking directly). Detected from whichever
|
||||
// settings response actually comes back — set once and used to hide the
|
||||
// proxy card/fields and to keep them out of the save request, since
|
||||
// param.cgi errors the whole call's status line if asked to set a
|
||||
// parameter name the manifest never registered.
|
||||
var hasProxyPorts = false;
|
||||
|
||||
function toggleProxyUi(visible) {
|
||||
hasProxyPorts = visible;
|
||||
var display = visible ? '' : 'none';
|
||||
document.getElementById('proxy-info-card').style.display = display;
|
||||
document.getElementById('http-port-row').style.display = display;
|
||||
document.getElementById('socks-port-row').style.display = display;
|
||||
}
|
||||
|
||||
function updateProxyDisplay(httpPort, socksPort) {
|
||||
if (httpPort) { cacheSet('http-port', httpPort); document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + httpPort; }
|
||||
if (socksPort) { cacheSet('socks-port', socksPort); document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + socksPort; }
|
||||
@@ -909,6 +925,7 @@
|
||||
if (dm) acceptDnsInput.checked = dm[1].trim() === 'true';
|
||||
if (rm) acceptRoutesInput.checked = rm[1].trim() === 'true';
|
||||
if (avm) advertiseRoutesInput.value = avm[1].trim();
|
||||
toggleProxyUi(!!hm && !!km);
|
||||
updateProxyDisplay(hm ? hm[1].trim() : null, km ? km[1].trim() : null);
|
||||
return true;
|
||||
}
|
||||
@@ -921,6 +938,7 @@
|
||||
if (typeof obj.AcceptDNS === 'string') acceptDnsInput.checked = obj.AcceptDNS === 'true';
|
||||
if (typeof obj.AcceptRoutes === 'string') acceptRoutesInput.checked = obj.AcceptRoutes === 'true';
|
||||
if (typeof obj.AdvertiseRoutes === 'string') advertiseRoutesInput.value = obj.AdvertiseRoutes;
|
||||
toggleProxyUi(typeof obj.HttpProxyPort === 'string' && typeof obj.Socks5Port === 'string');
|
||||
updateProxyDisplay(obj.HttpProxyPort, obj.Socks5Port);
|
||||
}
|
||||
|
||||
@@ -947,8 +965,8 @@
|
||||
function saveViaFallback(httpPort, socksPort) {
|
||||
var body = 'CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&Socks5Port=' + encodeURIComponent(socksPort) +
|
||||
(hasProxyPorts ? '&HttpProxyPort=' + encodeURIComponent(httpPort) : '') +
|
||||
(hasProxyPorts ? '&Socks5Port=' + encodeURIComponent(socksPort) : '') +
|
||||
'&AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
|
||||
'&AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
|
||||
'&AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
|
||||
@@ -978,8 +996,8 @@
|
||||
var params = 'action=update' +
|
||||
'&root.' + APP + '.CustomServer=' + encodeURIComponent(serverInput.value.trim()) +
|
||||
'&root.' + APP + '.AuthKey=' + encodeURIComponent(authInput.value.trim()) +
|
||||
'&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) +
|
||||
'&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) +
|
||||
(hasProxyPorts ? '&root.' + APP + '.HttpProxyPort=' + encodeURIComponent(httpPort) : '') +
|
||||
(hasProxyPorts ? '&root.' + APP + '.Socks5Port=' + encodeURIComponent(socksPort) : '') +
|
||||
'&root.' + APP + '.AcceptDNS=' + (acceptDnsInput.checked ? 'true' : 'false') +
|
||||
'&root.' + APP + '.AcceptRoutes=' + (acceptRoutesInput.checked ? 'true' : 'false') +
|
||||
'&root.' + APP + '.AdvertiseRoutes=' + encodeURIComponent(advertiseRoutesInput.value.trim());
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* ACAP parameter bridge for Tailscale VPN (userspace variant).
|
||||
* ACAP parameter bridge for Tailscale VPN.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
|
||||
@@ -13,7 +13,10 @@
|
||||
* Rapid changes within 300 ms are coalesced into a single restart.
|
||||
* 5. Watchdog: if the child exits unexpectedly, restart it.
|
||||
*
|
||||
* Runs as the unprivileged 'sdk' ACAP user — no root required.
|
||||
* Shared across the userspace-networking variants (unprivileged 'sdk' ACAP
|
||||
* user) and the ROOT / kernel-networking variant. Build with -DHAS_PROXY_PORTS
|
||||
* for the userspace variants, which exposes the HTTP/SOCKS5 proxy port
|
||||
* parameters; the ROOT variant omits them since it has no local proxy.
|
||||
*/
|
||||
|
||||
#include <axsdk/axparameter.h>
|
||||
@@ -37,14 +40,22 @@
|
||||
#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;
|
||||
@@ -98,7 +109,7 @@ static void start_child(void) {
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, NULL);
|
||||
execl(RUN_SCRIPT, RUN_SCRIPT, RUN_SCRIPT_VARIANT, NULL);
|
||||
syslog(LOG_ERR, "execl %s failed: %s", RUN_SCRIPT, strerror(errno));
|
||||
_exit(1);
|
||||
}
|
||||
@@ -178,8 +189,10 @@ static void load_config_cache(AXParameter *handle) {
|
||||
|
||||
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)
|
||||
@@ -195,17 +208,24 @@ static void write_config_file(void) {
|
||||
}
|
||||
fprintf(f, "CUSTOM_SERVER=%s\n", cache_get(&cfg_custom_server, ""));
|
||||
fprintf(f, "AUTH_KEY=%s\n", cache_get(&cfg_auth_key, ""));
|
||||
#ifdef HAS_PROXY_PORTS
|
||||
fprintf(f, "CONF_HTTP=%s\n", cache_get(&cfg_http_proxy_port, "8080"));
|
||||
fprintf(f, "CONF_SOCKS=%s\n", cache_get(&cfg_socks5_port, "1080"));
|
||||
#endif
|
||||
fprintf(f, "ACCEPT_DNS=%s\n", cache_get(&cfg_accept_dns, "false"));
|
||||
fprintf(f, "ACCEPT_ROUTES=%s\n", cache_get(&cfg_accept_routes, "false"));
|
||||
fprintf(f, "ADVERTISE_ROUTES=%s\n", cache_get(&cfg_advertise_routes, ""));
|
||||
fclose(f);
|
||||
chmod(CONFIG_FILE, 0600);
|
||||
#ifdef HAS_PROXY_PORTS
|
||||
syslog(LOG_INFO, "config updated: http=%s socks=%s server=%s",
|
||||
cache_get(&cfg_http_proxy_port, "8080"),
|
||||
cache_get(&cfg_socks5_port, "1080"),
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
#else
|
||||
syslog(LOG_INFO, "config updated: server=%s",
|
||||
cache_get(&cfg_custom_server, "(default)"));
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── ACAP parameter callback ─────────────────────────────────────────────── */
|
||||
@@ -230,8 +250,10 @@ static void parameter_changed(const gchar *name, const gchar *value,
|
||||
|
||||
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);
|
||||
@@ -251,15 +273,20 @@ static void parameter_changed(const gchar *name, const gchar *value,
|
||||
#define HTTP_PORT 2201
|
||||
|
||||
static const char *http_param_names[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
|
||||
"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);
|
||||
@@ -304,7 +331,12 @@ static gchar *http_build_settings_json(AXParameter *handle) {
|
||||
g_free(val);
|
||||
}
|
||||
g_string_append_c(out, '}');
|
||||
return g_string_free(out, FALSE);
|
||||
/* g_string_free(out, FALSE) is inlined by glib >= 2.76 headers into a call
|
||||
* to g_string_free_and_steal(), which doesn't exist in older glib runtimes
|
||||
* (e.g. AXIS OS 11.x). Copy out and fully free instead to stay portable. */
|
||||
gchar *json_result = g_strdup(out->str);
|
||||
g_string_free(out, TRUE);
|
||||
return json_result;
|
||||
}
|
||||
|
||||
static gchar *http_url_decode(const char *s, size_t len) {
|
||||
@@ -323,7 +355,9 @@ static gchar *http_url_decode(const char *s, size_t len) {
|
||||
g_string_append_c(out, c);
|
||||
}
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
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
|
||||
@@ -520,7 +554,10 @@ int main(void) {
|
||||
start_child();
|
||||
|
||||
const char *params[] = {
|
||||
"CustomServer", "AuthKey", "HttpProxyPort", "Socks5Port",
|
||||
"CustomServer", "AuthKey",
|
||||
#ifdef HAS_PROXY_PORTS
|
||||
"HttpProxyPort", "Socks5Port",
|
||||
#endif
|
||||
"AcceptDNS", "AcceptRoutes", "AdvertiseRoutes"
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(params) / sizeof(params[0]); i++) {
|
||||
Reference in New Issue
Block a user