Compare commits

..
32 Commits
Author SHA1 Message Date
Weston Blieden 0cbe14b8cf Trim redundant comments and correct stale ones 2026-09-25 09:59:40 +02:00
Weston Blieden 230a9f4861 Stop executing Tailscale settings as shell code
param_bridge wrote params.conf unquoted and the run script sourced it and ran tailscale up through eval, so a setting containing $(...) executed (as root in the ROOT variant). Values are now single-quoted shell literals and the up command is built as an argument list. AdvertiseRoutes also tolerates spaces after commas, which previously dropped every route. Verified on a P3288-LV (OS 12.11).
2026-09-25 08:49:21 +02:00
Weston Blieden 1997941965 ci: match the SDK approval rule regardless of registry prefix
matchPackageNames listed only axisecp/acap-native-sdk, but repos with ARG REPO=docker.io/axisecp resolve the dep as docker.io/axisecp/acap-native-sdk, which never matched. The approval gate was silently bypassed in Axis_Cam_OpenVPN and Axis_Cam_WireGuard, where SDK 12.11.0 was queued for automatic PR creation.
2026-09-21 10:27:19 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8b70caaf70 Update actions/checkout action to v7 (#38)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-21 08:32:00 +02:00
Weston Blieden 08f791ed24 ci: maintain build.yml in this repo instead of generating it
acap-ci.sh rendered this workflow from a shared template and renovate.json excluded it from updates. The generator was only ever run by hand, so the file is owned here now and Renovate maintains its action pins.
2026-09-21 08:28:08 +02:00
Weston Blieden 7d8d3a04cb ci: align Renovate config with the Go repos 2026-09-18 10:12:07 +02:00
Weston Blieden ccd670591e ci: scan UI JavaScript with CodeQL advanced setup 2026-09-18 09:07:56 +02:00
Weston Blieden 7dffcd1f58 ci: require approval for ACAP SDK bumps 2026-09-18 08:40:41 +02:00
Weston Blieden e23272fedd ci: adopt Renovate for dependency updates 2026-09-17 08:38:24 +02:00
Weston Blieden 559fe42294 Run tests/run.sh in CI when a repo has one
Regenerated from Axis_Cam_Template/ci/build.yml.tmpl via acap-ci.sh.
Repos without a tests/run.sh skip the step.
2026-09-15 12:46:05 +02:00
github-actions[bot] 3413d3a992 Update to 1.102.4 2026-09-11 09:27:07 +00:00
Weston Blieden f526196d99 Sync apply-version.sh: apply go.mod pins declared in .acap.json 2026-08-22 13:32:27 +02:00
Weston Blieden 14518ffe16 Strip inside the SDK container so every build matches CI
Stripping relied on host cross-binutils and was best-effort, so a machine without
them silently shipped a 68 MB package instead of 45 MB under the same version
number. Running the SDK's $STRIP in a container removes the host dependency.
Uses create/cp rather than a bind mount because podman machine on macOS only
mounts $HOME.
2026-08-22 13:22:01 +02:00
Weston Blieden d615191fc2 Count packages a build writes straight into releases/ 2026-08-22 08:27:52 +02:00
Weston Blieden d2f2cd13a4 Keep the UI update check in sync with the packaged version 2026-08-21 15:48:39 +02:00
Weston Blieden 96f5a7c909 Group Dependabot action updates into a single PR 2026-08-21 15:27:23 +02:00
Weston Blieden 86309e1291 Bump GitHub Actions to checkout v7, upload-artifact v7, download-artifact v8 2026-08-21 15:18:29 +02:00
Weston Blieden ed0f6d23fe Add Dependabot; never target an already-published version 2026-08-21 14:50:11 +02:00
Weston Blieden 474d783f48 Add Dependabot for GitHub Actions 2026-08-21 14:47:47 +02:00
Weston Blieden 9c1bec5043 Mirror upstream only when it is ahead of our version line 2026-08-21 14:37:23 +02:00
Weston Blieden 15ac2c3395 Only mention unsigned variants a release actually ships 2026-08-21 13:58:25 +02:00
Weston Blieden 437f7f9e40 Keep build output out of the version bump commit 2026-08-21 13:24:33 +02:00
Weston Blieden 53240ed2ec Add upstream-tracking release pipeline; extract build.sh 2026-08-21 13:07:28 +02:00
github-actions[bot] 492392d175 Update Tailscale to v1.102.3 2026-08-21 01:59:18 +00:00
github-actions[bot] e17549fef0 Update Tailscale to v1.102.2 2026-08-05 03:36:20 +00:00
github-actions[bot] 3a71aba342 Update Tailscale to v1.102.1 2026-08-04 03:35:42 +00:00
github-actions[bot] dce8beaa1b Update Tailscale to v1.98.10 2026-07-29 06:32:18 +00:00
Weston Blieden b70d664f91 fix: use Mo3he vendor name in ROOT manifests to pass schema validation 2026-07-29 08:15:03 +02:00
Weston Blieden 9ea88ccd0d docs: extend verified OS range to 13 (intro + roadmap) 2026-07-24 13:06:32 +02:00
Weston Blieden f79f631d95 docs: note verified on AXIS OS 13 (13.0.0, aarch64) 2026-07-24 13:03:13 +02:00
Weston Blieden 835ccff82f docs: add Advertise Routes (Subnet Router) to README config table 2026-07-21 19:44:51 +02:00
Weston Blieden 92262bd16c Update vendor to moshe@mohome.net and vendorId for ACAP signing
Packages are now signed with the Axis ACAP signing service. Document the
signing change and the upgrade steps (back up config and uninstall the old
version to avoid the "Vendor ID in manifest does not match" install error)
in the README and changelog.
2026-07-21 19:35:05 +02:00
30 changed files with 1147 additions and 577 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"app": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"versionPolicy": "mirror",
"upstream": {
"type": "script",
"script": "ci/upstream-version.sh",
"name": "Tailscale",
"changesUrl": "https://tailscale.com/changelog"
},
"build": {
"command": "./build.sh",
"env": {}
},
"pins": [],
"signing": {
"skip": [
"*_acap3.eap",
"*_root.eap"
]
}
}
+149 -186
View File
@@ -1,205 +1,168 @@
name: Auto Build & Release Tailscale ACAP
---
# Upstream release -> build -> DRAFT release holding unsigned .eap files.
# Signing is manual (Axis has no signing API); ../acap-sign.sh uploads the
# signed packages and publishes the release. The acap-ops repo notifies.
name: Build
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 0 * * *" # Every Monday at 03:00 UTC
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
version:
description: "Version to build. Empty resolves from upstream."
required: false
force:
description: "Rebuild and re-cut the draft even if unchanged."
type: boolean
default: false
permissions:
contents: write
concurrency:
group: acap-release-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-release:
check:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.decide.outputs.build }}
release: ${{ steps.decide.outputs.release }}
version: ${{ steps.decide.outputs.version }}
upstream: ${{ steps.decide.outputs.upstream }}
steps:
- uses: actions/checkout@v7
- id: decide
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_FORCE: ${{ github.event.inputs.force }}
EVENT_NAME: ${{ github.event_name }}
run: ./ci/resolve-version.sh
# 1. Checkout repo
- uses: actions/checkout@v3
with:
persist-credentials: true
fetch-depth: 0
build:
needs: check
if: needs.check.outputs.build == 'true'
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.check.outputs.version }}
UPSTREAM_VERSION: ${{ needs.check.outputs.upstream }}
steps:
- uses: actions/checkout@v7
# 2. Get latest Tailscale version
- name: Get latest Tailscale version
id: tailscale_version
run: |
# 1. Start from GitHub latest
GH_TAG=$(curl -s https://api.github.com/repos/tailscale/tailscale/releases/latest | jq -r .tag_name)
GH_VERSION=${GH_TAG#v}
- name: Apply version and upstream pins
run: ./ci/apply-version.sh "$VERSION" "$UPSTREAM_VERSION"
echo "GitHub latest: $GH_VERSION"
# 2. See if static ARM build exists for that version
if curl -sfI "https://pkgs.tailscale.com/stable/tailscale_${GH_VERSION}_arm.tgz" > /dev/null; then
VERSION="$GH_VERSION"
echo "Using GitHub latest (has ARM package): $VERSION"
else
echo "No ARM package for $GH_VERSION, falling back to latest version on pkgs.tailscale.com"
# 3. Derive latest version that actually has an ARM tarball
VERSION=$(
curl -s https://pkgs.tailscale.com/stable/ \
| grep -o 'tailscale_[0-9.]*_arm\.tgz' \
| sed -E 's/^tailscale_([0-9.]+)_arm\.tgz$/\1/' \
| sort -V | tail -n1
)
echo "Fallback version: $VERSION"
fi
echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Get current repo version
id: current
run: |
CURRENT=$(find . -path "*/app/manifest.json" -exec jq -r '.acapPackageConf.setup.version' {} \; | sort -u | head -n1)
echo "CURRENT_VERSION=$CURRENT" >> $GITHUB_ENV
echo "Current repo version: $CURRENT"
- name: Compare versions
id: compare
run: |
echo "Repo version: $CURRENT_VERSION"
echo "Latest Tailscale version: $RELEASE_VERSION"
echo "Trigger: ${{ github.event_name }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "build_needed=true" >> $GITHUB_ENV
echo "Manual trigger — building regardless of version."
elif [ "$CURRENT_VERSION" = "$RELEASE_VERSION" ]; then
echo "build_needed=false" >> $GITHUB_ENV
echo "Already up to date. Skipping build."
else
echo "build_needed=true" >> $GITHUB_ENV
echo "New version detected. Will build."
fi
# 3. Download Tailscale binaries
- name: Download Tailscale binaries
if: env.build_needed == 'true'
run: |
mkdir -p tailscale_bins
curl -L "https://pkgs.tailscale.com/stable/tailscale_${RELEASE_VERSION}_arm.tgz" -o tailscale_arm.tgz
tar -xzf tailscale_arm.tgz -C tailscale_bins --strip-components=1
mv tailscale_bins/tailscale tailscale_bins/tailscale_arm
mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm
curl -L "https://pkgs.tailscale.com/stable/tailscale_${RELEASE_VERSION}_arm64.tgz" -o tailscale_arm64.tgz
tar -xzf tailscale_arm64.tgz -C tailscale_bins --strip-components=1
mv tailscale_bins/tailscale tailscale_bins/tailscale_arm64
mv tailscale_bins/tailscaled tailscale_bins/tailscaled_arm64
# 4. Strip binaries to reduce package size
- name: Strip binaries
if: env.build_needed == 'true'
run: |
# Install cross-architecture strip tools
sudo apt-get update
sudo apt-get install -y binutils-aarch64-linux-gnu binutils-arm-linux-gnueabihf
# Strip debug info and symbol tables (zero runtime/memory cost)
aarch64-linux-gnu-strip -s tailscale_bins/tailscale_arm64
aarch64-linux-gnu-strip -s tailscale_bins/tailscaled_arm64
arm-linux-gnueabihf-strip -s tailscale_bins/tailscale_arm
arm-linux-gnueabihf-strip -s tailscale_bins/tailscaled_arm
ls -lh tailscale_bins/
# 5. Build each folder, update manifest, and copy .eap files
- name: Build all folders
if: env.build_needed == 'true'
run: |
mkdir -p build
rm -rf releases
mkdir -p releases
for folder in */ ; do
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 "$APP_LIB_DIR/tailscale"
cp tailscale_bins/tailscaled_arm "$APP_LIB_DIR/tailscaled"
# Repos without a tests/run.sh simply skip this.
- name: Run unit tests
run: |
set -euo pipefail
if [ -x tests/run.sh ] || [ -f tests/run.sh ]; then
sudo apt-get update
sudo apt-get install -y --no-install-recommends pkg-config libglib2.0-dev
sh tests/run.sh
else
cp tailscale_bins/tailscale_arm64 "$APP_LIB_DIR/tailscale"
cp tailscale_bins/tailscaled_arm64 "$APP_LIB_DIR/tailscaled"
echo "no tests/run.sh, skipping"
fi
# Detect variant suffix for .eap naming
if [[ "$FOLDER_NAME" == *_ROOT ]]; then
VARIANT="_root"
elif [[ "$FOLDER_NAME" == *_acap3 ]]; then
VARIANT="_acap3"
else
VARIANT=""
- name: Build packages
run: ./ci/build-packages.sh
- name: Verify packages
run: |
set -euo pipefail
shopt -s nullglob
packages=(releases/*.eap)
if [ ${#packages[@]} -eq 0 ]; then
echo "no .eap produced" >&2
exit 1
fi
# Update version — manifest.json for ACAP 4, package.conf for ACAP 3
if [[ -f "$folder/app/manifest.json" ]]; then
sed -i "s/\"version\": \".*\"/\"version\": \"${RELEASE_VERSION}\"/" "$folder/app/manifest.json"
elif [[ -f "$folder/app/package.conf" ]]; then
IFS='.' read -r MAJOR MINOR MICRO <<< "${RELEASE_VERSION}"
sed -i "s/^APPMAJORVERSION=.*/APPMAJORVERSION=${MAJOR}/" "$folder/app/package.conf"
sed -i "s/^APPMINORVERSION=.*/APPMINORVERSION=${MINOR}/" "$folder/app/package.conf"
sed -i "s/^APPMICROVERSION=.*/APPMICROVERSION=${MICRO}/" "$folder/app/package.conf"
fi
# Docker build
TAG_NAME=$(echo "$FOLDER_NAME" | tr '[:upper:]' '[:lower:]' | tr '/ ' '_') # lowercase and clean
echo "Building $TAG_NAME"
docker build -f "$folder/Dockerfile" --tag "$TAG_NAME" .
# Extract .eap files into build folder
EAP_OUTPUT="./build/${TAG_NAME}"
mkdir -p "$EAP_OUTPUT"
CID=$(docker create "$TAG_NAME")
docker cp "$CID":/opt/app "$EAP_OUTPUT"
docker rm "$CID" >/dev/null
# Move all .eap files to releases folder, append variant if needed
find "$EAP_OUTPUT" -type f -name "*.eap" | while read eap; do
BASENAME=$(basename "$eap" .eap)
if [[ -n "$VARIANT" ]]; then
mv "$eap" "releases/${BASENAME}${VARIANT}.eap"
else
mv "$eap" "releases/${BASENAME}.eap"
fi
for package in "${packages[@]}"; do
echo "== $package"
tar tzf "$package" >/dev/null
done
done
# Clean up
rm -rf build tailscale_bins *.tgz
- uses: actions/upload-artifact@v7
with:
name: packages
path: releases/*.eap
if-no-files-found: error
# 6. Commit updated manifests and .eap files directly to main
- name: Commit updates to main
if: env.build_needed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Only commit manifests and ACAP 3 package.conf; do not track release artifacts
git add */app/manifest.json arm_acap3/app/package.conf
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "Update Tailscale to v${RELEASE_VERSION}"
git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/Mo3he/Axis_Cam_Tailscale.git main
fi
# Unstripped binaries for symbolising a crash from a shipped (stripped)
# package. Not a release asset: they are only useful while debugging.
- uses: actions/upload-artifact@v7
with:
name: debug-symbols
path: debug/
if-no-files-found: ignore
# 7. Create GitHub Release with all new .eap files
- name: Create GitHub Release
if: env.build_needed == 'true'
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ env.RELEASE_VERSION }}
name: "Tailscale VPN ${{ env.RELEASE_VERSION }}"
files: releases/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Only after a successful build, so a failed upstream jump leaves main clean.
- name: Commit version bump
if: needs.check.outputs.release == 'true' && github.event_name != 'pull_request'
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Packages are already uploaded as an artifact; removing them here
# keeps build output out of the commit regardless of .gitignore.
rm -rf releases
git add -A
if git diff --cached --quiet; then
echo "nothing to commit"
exit 0
fi
git commit -m "Update to $VERSION"
# The remote can move while a long build runs, so rebase and retry.
for attempt in 1 2 3; do
if git push; then
exit 0
fi
echo "push rejected, rebasing (attempt $attempt)"
git pull --rebase --autostash origin main
done
echo "could not push the version bump" >&2
exit 1
release:
needs: [check, build]
if: needs.check.outputs.release == 'true' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.check.outputs.version }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
name: packages
path: releases
# Stays a DRAFT: unsigned packages must never reach users, and an
# already-published release must never be overwritten with unsigned ones.
- name: Create or refresh draft release
run: |
set -euo pipefail
tag="v$VERSION"
./ci/release-notes.sh "$VERSION" "${{ needs.check.outputs.upstream }}" > /tmp/notes.md
cat /tmp/notes.md
if gh release view "$tag" --json isDraft --jq '.isDraft' 2>/dev/null | grep -qx true; then
gh release upload "$tag" releases/*.eap --clobber
gh release edit "$tag" --notes-file /tmp/notes.md
elif gh release view "$tag" >/dev/null 2>&1; then
echo "release $tag is already published; refusing to touch it" >&2
exit 1
else
gh release create "$tag" releases/*.eap \
--draft \
--title "Tailscale VPN $VERSION" \
--notes-file /tmp/notes.md
fi
+41
View File
@@ -0,0 +1,41 @@
---
# Advanced setup: default setup cannot select javascript-typescript here
# because the UI scripts live inside index.html rather than a .js file.
name: CodeQL
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "24 4 * * 1"
permissions:
contents: read
jobs:
analyze:
name: Analyze ${{ matrix.language }}
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
language: [c-cpp, javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: none
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
+3
View File
@@ -6,6 +6,9 @@ releases/
build/
*.eap
# Unstripped binaries kept for crash symbolisation, uploaded as a CI artifact
debug/
# Do not track downloaded Tailscale tarballs and temp bins
tailscale_bins/
*.tgz
+15
View File
@@ -5,6 +5,21 @@ links to its full release notes on GitHub.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## 1.102.4 - 2026-09-11
- Update to upstream 1.102.4.
## [1.98.9-Signed] - 2026-07-21 - Tailscale VPN 1.98.9 (Signed)
- Packages are now signed with the Axis ACAP signing service and install
normally on AXIS OS 12.10 and later.
- Vendor updated to `moshe@mohome.net` with the registered vendor ID.
- `root` and `acap3` variants remain unsigned (manifest schema v1.x).
- Upgrading from an earlier unsigned version can fail with "Couldn't
install: app" (device log: "Vendor ID in manifest does not match the
vendor ID of the previous version"). Back up your config, uninstall the
old version, then install this one.
## [1.98.8-2] - 2026-07-03 - Tailscale VPN 1.98.8-2
## [1.98.8-subnet-routing] - 2026-07-01 - Tailscale VPN 1.98.8 - Subnet Routing
+17 -4
View File
@@ -46,7 +46,7 @@ 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–12.10).
- 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.
@@ -71,8 +71,19 @@ access for third-party applications). Learn more:
> root access for ACAPs). Use the **ACAP 3** build only on legacy cameras that
> don't support ACAP 4 (AXIS OS 9–10).
**Verified on AXIS OS 13** (13.0.0, aarch64).
## Installation
> **Signed packages:** Release `.eap` files are signed with the Axis ACAP
> signing service and install normally on AXIS OS 12.10 and later.
>
> **Upgrading from an earlier version?** The signing vendor changed, so
> installing over a previously installed unsigned build can fail with
> **"Couldn't install: app"** (device log: *"Vendor ID in manifest does not
> match the vendor ID of the previous version"*). To upgrade: back up your app
> configuration, **uninstall** the old version, then install the signed one.
Get the **prebuilt `.eap` file** from the
[Releases page](https://github.com/Mo3he/Axis_Cam_Tailscale/releases).
@@ -107,6 +118,7 @@ take effect immediately:
| SOCKS5 Proxy Port | `1080` | Port for the outbound SOCKS5 proxy. |
| Accept DNS | `off` | Passes `--accept-dns=true` to `tailscale up`. Allows the tailnet to push DNS settings to the camera. Not available on `armv7hf_acap3`. |
| Accept Routes | `off` | Passes `--accept-routes=true` to `tailscale up`. Allows the camera to use subnet routes advertised by other nodes. Not available on `armv7hf_acap3`. |
| Advertise Routes (Subnet Router) | *(empty)* | Comma-separated CIDRs (e.g. `192.168.1.0/24,10.0.0.0/8`) this camera will route for the tailnet, turning it into a subnet router. Approve the routes in the Tailscale admin console after saving. Leave blank to disable. |
## Ports & security
@@ -209,14 +221,15 @@ announcement for details.
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–12.10.
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.
- [ ] **Sign the ACAP via the Axis ACAP Portal** - Deferred; the manifest's
`vendorId` is a placeholder value, not yet portal-registered.
- [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
-1
View File
@@ -6,7 +6,6 @@ ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY common/app /opt/app/
COPY aarch64/app/manifest.json /opt/app/manifest.json
WORKDIR /opt/app
+63 -63
View File
@@ -1,67 +1,67 @@
{
"schemaVersion": "2.0.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "Mo3he",
"vendorId": "5741c1fb91",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"version": "1.98.9",
"architecture": "aarch64",
"runMode": "respawn",
"compatibleOsVersions": [
{
"max": "13"
}
]
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
"schemaVersion": "2.0.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "moshe@mohome.net",
"vendorId": "70ee172dd9",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"version": "1.102.4",
"architecture": "aarch64",
"runMode": "respawn",
"compatibleOsVersions": [
{
"max": "13"
}
]
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
}
}
-1
View File
@@ -6,7 +6,6 @@ ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY common/app /opt/app/
COPY aarch64_ROOT/app/manifest.json /opt/app/manifest.json
WORKDIR /opt/app
+52 -52
View File
@@ -1,56 +1,56 @@
{
"schemaVersion": "1.7.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "Mo3he",
"embeddedSdkVersion": "3.0",
"user": {
"group": "root",
"username": "root"
},
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.98.9",
"architecture": "aarch64"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
"schemaVersion": "1.7.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "Mo3he",
"embeddedSdkVersion": "3.0",
"user": {
"group": "root",
"username": "root"
},
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.102.4",
"architecture": "aarch64"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
}
}
-1
View File
@@ -6,7 +6,6 @@ ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY common/app /opt/app/
COPY arm/app/manifest.json /opt/app/manifest.json
WORKDIR /opt/app
+63 -63
View File
@@ -1,67 +1,67 @@
{
"schemaVersion": "2.0.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "Mo3he",
"vendorId": "5741c1fb91",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"version": "1.98.9",
"architecture": "armv7hf",
"runMode": "respawn",
"compatibleOsVersions": [
{
"max": "13"
}
]
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
"schemaVersion": "2.0.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "moshe@mohome.net",
"vendorId": "70ee172dd9",
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"version": "1.102.4",
"architecture": "armv7hf",
"runMode": "respawn",
"compatibleOsVersions": [
{
"max": "13"
}
]
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "HttpProxyPort",
"default": "8080",
"type": "string"
},
{
"name": "Socks5Port",
"default": "1080",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
}
}
-1
View File
@@ -6,7 +6,6 @@ ARG SDK=acap-native-sdk
FROM ${REPO}/${SDK}:${VERSION}-${ARCH}-ubuntu${UBUNTU_VERSION}
# Building the ACAP application
COPY common/app /opt/app/
COPY arm_ROOT/app/manifest.json /opt/app/manifest.json
WORKDIR /opt/app
+52 -52
View File
@@ -1,56 +1,56 @@
{
"schemaVersion": "1.7.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "Mo3he",
"embeddedSdkVersion": "3.0",
"user": {
"group": "root",
"username": "root"
},
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.98.9",
"architecture": "armv7hf"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
"schemaVersion": "1.7.0",
"acapPackageConf": {
"setup": {
"appName": "Tailscale_VPN",
"friendlyName": "Tailscale VPN",
"vendor": "Mo3he",
"embeddedSdkVersion": "3.0",
"user": {
"group": "root",
"username": "root"
},
"vendorUrl": "https://github.com/Mo3he/Axis_Cam_Tailscale",
"runMode": "respawn",
"version": "1.102.4",
"architecture": "armv7hf"
},
"configuration": {
"settingPage": "index.html",
"reverseProxy": [
{
"apiPath": "api",
"target": "http://localhost:2201/",
"access": "admin"
}
],
"paramConfig": [
{
"name": "CustomServer",
"default": "",
"type": "string"
},
{
"name": "AuthKey",
"default": "",
"type": "string"
},
{
"name": "AcceptDNS",
"default": "false",
"type": "string"
},
{
"name": "AcceptRoutes",
"default": "false",
"type": "string"
},
{
"name": "AdvertiseRoutes",
"default": "",
"type": "string"
}
]
}
}
}
+6 -14
View File
@@ -7,18 +7,15 @@ RUN apt-get update -qq && apt-get install -y --no-install-recommends upx-ucl &&
COPY arm_acap3/app /opt/app/
WORKDIR /opt/app
# Rename the shell startup script (the ELF launcher will take the Tailscale_VPN name)
# The ELF launcher takes over the Tailscale_VPN name.
RUN mv Tailscale_VPN start.sh && chmod +x start.sh
# Compile a minimal ELF launcher as APPNAME.
# ACAP 3 elflibcheck requires an ELF binary and uses pidof(APPNAME) for status.
# We fork+exec start.sh so the parent "Tailscale_VPN" process stays resident,
# making pidof find it and the camera UI correctly show Running/Stopped.
# ACAP 3 elflibcheck needs APPNAME to be ELF and uses pidof(APPNAME) for status.
RUN . /opt/axis/acapsdk/environment-setup* && \
${CC} -o Tailscale_VPN launcher.c && \
${STRIP} -s Tailscale_VPN
# Strip then UPX-compress the Tailscale binaries so they fit on flash
# UPX so the binaries fit on flash
RUN . /opt/axis/acapsdk/environment-setup* && \
${STRIP} -s lib/tailscale lib/tailscaled 2>/dev/null || true && \
upx --best lib/tailscale lib/tailscaled
@@ -26,20 +23,15 @@ RUN . /opt/axis/acapsdk/environment-setup* && \
# ACAP 3 firmware expects the settings page at the app root, not in html/
RUN cp html/index.html index.html
# Symlink tailscaled.log into html/ so the web UI can fetch it via HTTP.
# The log is written at runtime to localdata/ (resolved path at runtime).
# Runtime files live in localdata/; symlink them so the web UI can fetch them.
RUN ln -sf ../localdata/tailscaled.log html/tailscaled.log
# Symlink the runtime status.json (written by start.sh from `tailscale status
# --json`) into html/ so the web UI can read Tailscale's authoritative state.
RUN ln -sf ../localdata/status.json html/status.json
# Build and package
RUN . /opt/axis/acapsdk/environment-setup* && create-package.sh ./
# Patch STARTMODE: create-package.sh hardcodes "never" unless RESTRICTION_STARTMODE is set,
# but the ACAP 3 SDK does not honour our package.conf's STARTMODE=respawn without it.
# Repack the .eap with the corrected value.
# create-package.sh hardcodes STARTMODE="never" (ignoring package.conf) unless
# RESTRICTION_STARTMODE is set, so repack the .eap with respawn.
RUN for eap in *.eap; do \
tmpdir=$(mktemp -d) && tar xf "$eap" -C "$tmpdir" && \
sed -i 's/STARTMODE="never"/STARTMODE="respawn"/' "$tmpdir/package.conf" && \
+7 -17
View File
@@ -300,14 +300,12 @@
</button>
</div>
<!-- Status -->
<div id="status-banner" class="status-banner connecting">
<span class="dot"></span>
<span id="status-text" class="status-text">Checking...</span>
<span id="status-time" class="status-time"></span>
</div>
<!-- Update available -->
<div id="update-banner" class="update-banner">
<div class="update-text">Update available: <strong id="update-version"></strong></div>
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
@@ -316,7 +314,6 @@
</a>
</div>
<!-- Auth (hidden by default) -->
<div id="auth-block" class="auth-block" style="display:none;">
<p>Authenticate this device to connect to your Tailscale network:</p>
<a id="auth-link" class="auth-btn" href="#" target="_blank">
@@ -326,7 +323,6 @@
<span id="auth-url-text" class="auth-url"></span>
</div>
<!-- Connection Info -->
<div class="card" id="info-card" style="display:none;">
<div class="card-title">Connection Details</div>
<div class="info-grid">
@@ -352,7 +348,6 @@
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
@@ -436,11 +431,11 @@
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
// The syslog header always carries the real device hostname.
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
// Fallback; may contain a stale acap-tailscale_vpn name.
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
@@ -493,7 +488,6 @@
if (acap3ip) tsIP = acap3ip[1];
}
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
tsIP = tsIP || cacheGet('ip');
node = node || cacheGet('node');
@@ -609,10 +603,8 @@
.catch(function() { return false; });
}
// ACAP3: also fetch the raw tailscaled.log (symlinked into html/) so the parser
// can find IP, version, tailnet and Running state from tailscaled's own output.
// ACAP3: tailscaled's own log has the IP, version, tailnet and Running state.
var DAEMON_LOG_URL = 'tailscaled.log';
// Authoritative backend state published by start.sh (symlinked into html/).
var STATUS_URL = 'status.json';
// Ground truth published by start.sh from `tailscale status --json`.
@@ -633,7 +625,6 @@
if (st.Version) result.version = String(st.Version).split('-')[0];
if (bs === 'Running' && self.Online === true) {
// Genuinely connected and reachable on the tailnet
result.state = 'connected';
result.url = null;
result.ip = ip4 || result.ip;
@@ -645,9 +636,8 @@
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Running') {
// Backend running but node not online: transient network drop or the
// node was removed/expired and needs re-auth. Not connected. Keep any
// login URL the log parser found so the login button still appears.
// Not online: transient drop or node removed/expired. Keep the log
// parser's URL so the login button still appears.
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Stopped') {
@@ -672,8 +662,8 @@
var txt = res[0] + '\n' + res[1];
var st = res[2];
var result = parse(txt);
renderLogs(res[0]); // show syslog in log panel; daemon log is too verbose
// Always verify with the app status API - logs can have stale entries
renderLogs(res[0]); // daemon log is too verbose to show
// Logs can have stale entries; confirm the app is running.
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
+3 -15
View File
@@ -4,19 +4,9 @@
#include <signal.h>
/*
* ACAP 3 supervisor launcher for Tailscale_VPN.
*
* elflibcheck.sh requires APPNAME to be an ELF binary.
* acap-startstop / respawnd / list.cgi all use pidof(APPNAME) for status.
*
* This binary NEVER exits voluntarily — it loops restarting start.sh if it
* dies, so:
* - pidof Tailscale_VPN always finds this process → UI shows "Running"
* - respawnd never triggers (it only fires when APPNAME exits)
* - If tailscaled OOMs and start.sh exits, we cleanly restart it
*
* To stop the app, acap-startstop calls stop_daemon which sends SIGTERM here.
* We forward SIGTERM/SIGINT to the child and then exit.
* ACAP 3 launcher: elflibcheck needs APPNAME to be ELF, and acap-startstop,
* respawnd and list.cgi use pidof(APPNAME). So this stays resident, restarts
* start.sh whenever it dies, and only exits on SIGTERM/SIGINT.
*/
static volatile int g_stop = 0;
@@ -37,7 +27,6 @@ int main(void)
while (!g_stop) {
pid_t pid = fork();
if (pid == 0) {
/* child: reset signals and exec start.sh */
signal(SIGTERM, SIG_DFL);
signal(SIGINT, SIG_DFL);
execl("/usr/local/packages/Tailscale_VPN/start.sh",
@@ -59,7 +48,6 @@ int main(void)
g_child = -1;
if (!g_stop) {
/* start.sh died unexpectedly — wait before restarting */
sleep(3);
}
}
+2 -2
View File
@@ -2,8 +2,8 @@ PACKAGENAME=Tailscale_VPN
MENUNAME="Tailscale VPN"
VENDOR="Mo3he"
APPMAJORVERSION=1
APPMINORVERSION=98
APPMICROVERSION=9
APPMINORVERSION=102
APPMICROVERSION=4
APPTYPE=armv7hf
APPNAME=Tailscale_VPN
APPOPTS=""
Executable
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env sh
# Build the Tailscale ACAP variants.
#
# ./build.sh # build every variant
# ./build.sh aarch64 arm # build only the named variant folders
#
# Variant folders map to release .eap suffixes: *_ROOT -> _root, *_acap3 -> _acap3.
# RUNTIME=docker|podman forces a container runtime; TAILSCALE_VERSION overrides
# the version resolved by ci/upstream-version.sh.
set -eu
REPO_ROOT=$(cd -P "$(dirname "$0")" && pwd)
cd "$REPO_ROOT"
if [ -z "${RUNTIME:-}" ]; then
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
RUNTIME=docker
elif command -v podman >/dev/null 2>&1; then
RUNTIME=podman
else
echo 'Error: neither docker nor podman found in PATH' >&2
exit 1
fi
fi
echo "==> Using container runtime: ${RUNTIME}"
VERSION="${TAILSCALE_VERSION:-$(sh ci/upstream-version.sh)}"
[ -n "$VERSION" ] || {
echo 'Error: could not resolve a Tailscale version' >&2
exit 1
}
echo "==> Tailscale version: ${VERSION}"
# --- fetch and strip upstream binaries ---------------------------------------
BINS="${REPO_ROOT}/tailscale_bins"
rm -rf "$BINS"
rm -rf "${REPO_ROOT}/debug"
mkdir -p "$BINS"
fetch_arch() {
tgz_arch=$1
suffix=$2
echo "==> Downloading tailscale ${VERSION} (${tgz_arch})"
curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${VERSION}_${tgz_arch}.tgz" \
-o "${BINS}/ts_${suffix}.tgz"
tar -xzf "${BINS}/ts_${suffix}.tgz" -C "$BINS" --strip-components=1
mv "${BINS}/tailscale" "${BINS}/tailscale_${suffix}"
mv "${BINS}/tailscaled" "${BINS}/tailscaled_${suffix}"
rm -f "${BINS}/ts_${suffix}.tgz"
}
fetch_arch arm arm
fetch_arch arm64 arm64
# Upstream ships unstripped binaries; stripping saves ~23 MB per package. Do it
# inside the SDK container: without host cross-binutils it silently did nothing.
SDK_IMAGE=axisecp/acap-native-sdk:12.10.0
SDK_UBUNTU=ubuntu24.04
strip_arch() {
sdk_arch=$1
suffix=$2
echo "==> Stripping ${suffix} binaries"
# Unstripped copies for symbolising crash dumps; never shipped.
mkdir -p "${REPO_ROOT}/debug"
cp "${BINS}/tailscale_${suffix}" "${REPO_ROOT}/debug/tailscale-${suffix}.unstripped"
cp "${BINS}/tailscaled_${suffix}" "${REPO_ROOT}/debug/tailscaled-${suffix}.unstripped"
# SC2016: $STRIP must expand inside the container, not on the host.
# shellcheck disable=SC2016
cid=$("$RUNTIME" create "${SDK_IMAGE}-${sdk_arch}-${SDK_UBUNTU}" sh -c \
'. /opt/axis/acapsdk/environment-setup* >/dev/null 2>&1 && "${STRIP:?SDK environment did not set STRIP}" /tmp/tailscale /tmp/tailscaled')
"$RUNTIME" cp "${BINS}/tailscale_${suffix}" "${cid}:/tmp/tailscale"
"$RUNTIME" cp "${BINS}/tailscaled_${suffix}" "${cid}:/tmp/tailscaled"
"$RUNTIME" start -a "$cid"
"$RUNTIME" cp "${cid}:/tmp/tailscale" "${BINS}/tailscale_${suffix}"
"$RUNTIME" cp "${cid}:/tmp/tailscaled" "${BINS}/tailscaled_${suffix}"
"$RUNTIME" rm "$cid" >/dev/null
}
strip_arch aarch64 arm64
strip_arch armv7hf arm
# --- build variants -----------------------------------------------------------
echo '==> Cleaning old .eap files...'
rm -f "${REPO_ROOT}"/*.eap
rm -rf "${REPO_ROOT}/build"
build_variant() {
folder=${1%/}
[ -d "${folder}/app" ] || return 0
[ "$folder" = common ] && return 0
# aarch64/arm/aarch64_ROOT/arm_ROOT share sources via common/app; only
# arm_acap3 carries its own self-contained app tree.
case "$folder" in
aarch64 | arm | aarch64_ROOT | arm_ROOT) lib_dir="common/app/lib" ;;
*) lib_dir="${folder}/app/lib" ;;
esac
mkdir -p "$lib_dir"
case "$folder" in
arm*) src=arm ;;
*) src=arm64 ;;
esac
cp "${BINS}/tailscale_${src}" "${lib_dir}/tailscale"
cp "${BINS}/tailscaled_${src}" "${lib_dir}/tailscaled"
case "$folder" in
*_ROOT) variant="_root" ;;
*_acap3) variant="_acap3" ;;
*) variant="" ;;
esac
tag=$(echo "$folder" | tr '[:upper:]' '[:lower:]' | tr '/ ' '__')
echo "==> Building ${folder}"
"$RUNTIME" build -f "${folder}/Dockerfile" --tag "$tag" .
out="${REPO_ROOT}/build/${tag}"
mkdir -p "$out"
cid=$("$RUNTIME" create "$tag")
"$RUNTIME" cp "${cid}:/opt/app" "$out"
"$RUNTIME" rm "$cid" >/dev/null
find "$out" -type f -name '*.eap' | while read -r eap; do
base=$(basename "$eap" .eap)
mv "$eap" "${REPO_ROOT}/${base}${variant}.eap"
done
}
if [ "$#" -eq 0 ]; then
set -- */
fi
for v in "$@"; do
build_variant "$v"
done
rm -rf "${REPO_ROOT}/build" "$BINS"
echo '==> Done!'
ls -lh "${REPO_ROOT}"/*.eap 2>/dev/null || true
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
#
# Write a version into every place this repo records it and refresh the
# upstream pins declared in .acap.json.
#
# Usage: ci/apply-version.sh <version> [upstream-version]
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION=${1:?version required}
UPSTREAM=${2:-}
CONFIG=.acap.json
cfg() { jq -r "$1" "$CONFIG"; }
IFS='.' read -r MAJOR MINOR MICRO <<<"$VERSION"
while IFS= read -r manifest; do
[ -n "$manifest" ] || continue
tmp=$(mktemp)
jq --arg v "$VERSION" '.acapPackageConf.setup.version = $v' "$manifest" >"$tmp"
mv "$tmp" "$manifest"
echo "version $VERSION -> $manifest"
done < <(find . -path '*/app/manifest.json' -not -path './node_modules/*' | sort)
while IFS= read -r conf; do
[ -n "$conf" ] || continue
sed -i.bak -E \
-e "s/^APPMAJORVERSION=.*/APPMAJORVERSION=${MAJOR}/" \
-e "s/^APPMINORVERSION=.*/APPMINORVERSION=${MINOR}/" \
-e "s/^APPMICROVERSION=.*/APPMICROVERSION=${MICRO}/" \
-e "s/^VERSION=.*/VERSION=${VERSION}/" \
"$conf"
rm -f "$conf.bak"
echo "version $VERSION -> $conf"
done < <(find . -path '*/app/package.conf' | sort)
pin_count=$(cfg '.pins | length')
for ((i = 0; i < pin_count; i++)); do
file=$(cfg ".pins[$i].file")
arg=$(cfg ".pins[$i].arg")
prefix=$(cfg ".pins[$i].prefix // empty")
sha_url=$(cfg ".pins[$i].sha256Url // empty")
gomodule=$(cfg ".pins[$i].goModule // empty")
[ -f "$file" ] || {
echo "pin target missing: $file" >&2
continue
}
# A go.mod pin has no ARG to substitute, and go refuses a bare "0.77.1", so
# the declared prefix has to be applied here.
if [ -n "$gomodule" ]; then
(cd "$(dirname "$file")" && go get "${gomodule}@${prefix}${UPSTREAM:-$VERSION}" && go mod tidy)
echo "go module ${gomodule}@${prefix}${UPSTREAM:-$VERSION} -> $file"
continue
fi
if [ -n "$sha_url" ]; then
# Checksum pins track the version pin, so the tarball is fetched and
# hashed rather than substituted.
url=${sha_url//\$\{VERSION\}/${UPSTREAM:-$VERSION}}
echo "hashing $url"
value=$(curl -fsSL "$url" | sha256sum | awk '{print $1}')
else
value="${prefix}${UPSTREAM:-$VERSION}"
fi
sed -i.bak -E "s|^ARG ${arg}=.*|ARG ${arg}=${value}|" "$file"
rm -f "$file.bak"
echo "pin ${arg}=${value} -> $file"
done
module=$(cfg '.upstream.module // empty')
gomod=$(cfg '.upstream.goMod // empty')
if [ -n "$module" ] && [ -n "$UPSTREAM" ] && [ -f "$gomod" ]; then
(cd "$(dirname "$gomod")" && go get "${module}@${UPSTREAM}" && go mod tidy)
echo "go module ${module}@${UPSTREAM}"
fi
# Web UIs compare the installed version against the latest GitHub release. The
# literal is marked so it cannot drift out of sync with the manifest.
while IFS= read -r page; do
[ -n "$page" ] || continue
sed -i.bak -E "s|'[0-9]+\.[0-9]+\.[0-9]+'( /\* acap:installed-version \*/)|'${VERSION}'\1|g" "$page"
rm -f "$page.bak"
echo "installed-version $VERSION -> $page"
done < <(grep -rl 'acap:installed-version' --include='*.html' . 2>/dev/null || true)
if [ -f CHANGELOG.md ] && ! grep -qE "^## \[?${VERSION}\]?" CHANGELOG.md; then
first_heading=$(grep -n -m1 '^## ' CHANGELOG.md | cut -d: -f1 || true)
tmp=$(mktemp)
{
if [ -n "$first_heading" ]; then
head -n "$((first_heading - 1))" CHANGELOG.md
else
cat CHANGELOG.md
echo
fi
echo "## ${VERSION} - $(date +%Y-%m-%d)"
echo
if [ -n "$UPSTREAM" ]; then
echo "- Update to upstream ${UPSTREAM}."
else
echo "- Release ${VERSION}."
fi
echo
[ -n "$first_heading" ] && tail -n +"$first_heading" CHANGELOG.md
} >"$tmp"
mv "$tmp" CHANGELOG.md
echo "changelog entry added for $VERSION"
fi
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Run the repo's build and collect every .eap into releases/.
# The build command and extra env come from .acap.json.
set -euo pipefail
cd "$(dirname "$0")/.."
CONFIG=.acap.json
cfg() { jq -r "$1" "$CONFIG"; }
COMMAND=$(cfg '.build.command')
while IFS=$'\t' read -r key value; do
[ -n "$key" ] || continue
value=${value//\$\{VERSION\}/${VERSION:-}}
export "$key=$value"
echo "env $key=$value"
done < <(cfg '.build.env | to_entries[]? | [.key, .value] | @tsv')
rm -rf releases
mkdir -p releases
echo "== $COMMAND"
eval "$COMMAND"
# Repos drop packages in the root, build/, build_<arch>/ or straight into
# releases/ depending on the repo, so gather any strays and then count what
# actually ended up in releases/.
while IFS= read -r package; do
[ -n "$package" ] || continue
mv "$package" releases/
done < <(find . -name '*.eap' -not -path './releases/*' -not -path './.git/*')
found=$(find releases -name '*.eap' | wc -l | tr -d ' ')
[ "$found" -gt 0 ] || {
echo "no .eap produced" >&2
exit 1
}
echo "collected $found package(s):"
ls -lh releases/
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# Generate release notes for a draft release.
#
# Usage: ci/release-notes.sh <version> [upstream-version] > notes.md
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION=${1:?version required}
UPSTREAM=${2:-}
CONFIG=.acap.json
cfg() { jq -r "$1" "$CONFIG"; }
FRIENDLY=$(cfg '.friendlyName')
UPSTREAM_NAME=$(cfg '.upstream.name // .upstream.repo // .upstream.module // empty')
CHANGES_URL=$(cfg '.upstream.changesUrl // empty')
CHANGES_URL=${CHANGES_URL//\$\{UPSTREAM\}/$UPSTREAM}
# Previous tag, so the compare link points somewhere useful.
PREVIOUS=$(git tag --list 'v*' --sort=-v:refname | grep -v "^v${VERSION}$" | head -1 || true)
printf '%s %s\n\n' "$FRIENDLY" "$VERSION"
if [ -n "$UPSTREAM" ] && [ -n "$UPSTREAM_NAME" ]; then
printf 'Packages **%s `%s`**.\n\n' "$UPSTREAM_NAME" "$UPSTREAM"
fi
if [ -n "$CHANGES_URL" ]; then
printf '### Upstream changes\n\n%s\n\n' "$CHANGES_URL"
fi
if [ -f CHANGELOG.md ]; then
# Pull just this version's section out of the changelog.
section=$(awk -v v="$VERSION" '
$0 ~ "^## \\[?" v "\\]?" { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed '/^[[:space:]]*$/d')
if [ -n "$section" ]; then
printf '### Changes\n\n%s\n\n' "$section"
fi
fi
printf '### Packages\n\n'
printf 'Install the `signed_*.eap` matching your device architecture.\n\n'
# Only explain the unsigned variants when this release actually ships them.
unsigned_note=''
if compgen -G 'releases/*_acap3.eap' >/dev/null 2>&1; then
unsigned_note='`_acap3`'
fi
if compgen -G 'releases/*_root.eap' >/dev/null 2>&1; then
[ -n "$unsigned_note" ] && unsigned_note="${unsigned_note} and "
unsigned_note="${unsigned_note}\`_root\`"
fi
if [ -n "$unsigned_note" ]; then
printf 'Packages ending %s are published unsigned by design:\n' "$unsigned_note"
printf 'they use manifest schema 1.x, which the Axis signing service does not accept.\n\n'
fi
if [ -n "$PREVIOUS" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then
printf '**Full changelog**: https://github.com/%s/compare/%s...v%s\n' \
"$GITHUB_REPOSITORY" "$PREVIOUS" "$VERSION"
fi
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
#
# Decide which version this repo should build, from .acap.json.
# Writes build/release/version/upstream to GITHUB_OUTPUT under CI, and always
# prints the decision so it can be run locally to preview.
#
# Policies:
# mirror the ACAP version follows the upstream version exactly.
# patch upstream is tracked through a pin; our own last digit is bumped.
set -euo pipefail
cd "$(dirname "$0")/.."
CONFIG=.acap.json
[ -f "$CONFIG" ] || {
echo "missing $CONFIG" >&2
exit 1
}
cfg() { jq -r "$1" "$CONFIG"; }
POLICY=$(cfg '.versionPolicy')
UPSTREAM_TYPE=$(cfg '.upstream.type')
EVENT_NAME=${EVENT_NAME:-manual}
INPUT_VERSION=${INPUT_VERSION:-}
INPUT_FORCE=${INPUT_FORCE:-false}
current_version() {
local manifest conf
manifest=$(find . -path '*/app/manifest.json' -not -path './node_modules/*' | sort | head -1)
if [ -n "$manifest" ]; then
jq -r '.acapPackageConf.setup.version' "$manifest"
return
fi
conf=$(find . -path '*/app/package.conf' | sort | head -1)
[ -n "$conf" ] && sed -n 's/^VERSION=//p' "$conf" | head -1
}
# Current value of the first pin, used by "patch" to detect upstream movement.
pin_value() {
local file arg gomod module
file=$(cfg '.pins[0].file // empty')
arg=$(cfg '.pins[0].arg // empty')
if [ -n "$file" ] && [ -n "$arg" ] && [ -f "$file" ]; then
sed -n "s/^ARG ${arg}=//p" "$file" | head -1
return
fi
gomod=$(cfg '.upstream.goMod // empty')
module=$(cfg '.upstream.module // empty')
if [ -n "$gomod" ] && [ -f "$gomod" ]; then
# The module may appear as "require mod ver" or as "mod ver" inside a
# require block, so take the field after the module name wherever it is.
awk -v m="$module" '{ for (i = 1; i < NF; i++) if ($i == m) { print $(i + 1); exit } }' "$gomod"
fi
}
# FFmpeg and openvpn3 publish no releases, and their tag lists contain names
# that are not versions, hence the explicit pattern per repo.
upstream_version() {
case "$UPSTREAM_TYPE" in
github-release)
local tag
tag=$(gh api "repos/$(cfg '.upstream.repo')/releases/latest" --jq '.tag_name')
[ "$(cfg '.upstream.stripV // false')" = true ] && tag=${tag#v}
printf '%s\n' "$tag"
;;
github-tag)
gh api "repos/$(cfg '.upstream.repo')/tags?per_page=100" --paginate --jq '.[].name' |
grep -E "$(cfg '.upstream.tagPattern')" |
sed "s|^$(cfg '.upstream.strip // empty')||" |
sort -V | tail -1
;;
go-module)
curl -fsSL "https://proxy.golang.org/$(cfg '.upstream.module')/@latest" | jq -r '.Version'
;;
script)
bash "$(cfg '.upstream.script')"
;;
*)
echo ''
;;
esac
}
bump_patch() {
local major minor patch
IFS='.' read -r major minor patch <<<"$1"
printf '%s.%s.%s\n' "${major:-0}" "${minor:-0}" "$((${patch:-0} + 1))"
}
# True when $1 is a strictly higher version than $2.
version_gt() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ]
}
CURRENT=$(current_version)
UPSTREAM=$(upstream_version || true)
BUILD=false
RELEASE=false
TARGET="$CURRENT"
if [ -n "$INPUT_VERSION" ]; then
TARGET=${INPUT_VERSION#v}
BUILD=true
RELEASE=true
elif [ "$POLICY" = mirror ]; then
if [ -n "$UPSTREAM" ] && [ "$UPSTREAM" != "$CURRENT" ]; then
if version_gt "$UPSTREAM" "$CURRENT"; then
# Upstream is ahead: adopt its version.
TARGET="$UPSTREAM"
BUILD=true
RELEASE=true
elif [ "$UPSTREAM" != "$(pin_value)" ]; then
# Our line already ran past upstream, so keep moving forward on it
# rather than emitting a lower version that clashes with old tags.
TARGET=$(bump_patch "$CURRENT")
BUILD=true
RELEASE=true
fi
fi
elif [ "$POLICY" = patch ]; then
if [ -n "$UPSTREAM" ] && [ "$UPSTREAM" != "$(pin_value)" ]; then
TARGET=$(bump_patch "$CURRENT")
BUILD=true
RELEASE=true
fi
fi
# Pull requests build for validation but never release.
if [ "$EVENT_NAME" = pull_request ]; then
BUILD=true
RELEASE=false
fi
if [ "$INPUT_FORCE" = true ]; then
BUILD=true
RELEASE=true
fi
# Never aim at a version that is already published. Stepping forward here means
# a long build is not wasted only to be rejected by the release job. An explicit
# version input is respected as given.
if [ "$RELEASE" = true ] && [ -z "$INPUT_VERSION" ] && command -v gh >/dev/null 2>&1; then
attempts=0
while [ "$attempts" -lt 20 ] &&
gh release view "v$TARGET" --json isDraft --jq '.isDraft' 2>/dev/null | grep -qx false; do
echo "v$TARGET is already published; stepping forward"
TARGET=$(bump_patch "$TARGET")
attempts=$((attempts + 1))
done
fi
cat <<EOF
policy : $POLICY
current : $CURRENT
upstream : ${UPSTREAM:-n/a}
target : $TARGET
build : $BUILD
release : $RELEASE
EOF
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "build=$BUILD"
echo "release=$RELEASE"
echo "version=$TARGET"
echo "upstream=$UPSTREAM"
} >>"$GITHUB_OUTPUT"
fi
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
# Resolve the Tailscale version to package.
#
# Tailscale's GitHub "latest" release sometimes lands before the static ARM
# tarballs are published, so fall back to the newest version that actually has
# an ARM package on pkgs.tailscale.com.
set -eu
GH_VERSION=$(curl -fsS https://api.github.com/repos/tailscale/tailscale/releases/latest |
sed -n 's/.*"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p' | head -1)
if [ -n "${GH_VERSION}" ] &&
curl -sfI "https://pkgs.tailscale.com/stable/tailscale_${GH_VERSION}_arm.tgz" >/dev/null 2>&1; then
printf '%s\n' "${GH_VERSION}"
exit 0
fi
curl -fsS https://pkgs.tailscale.com/stable/ |
grep -o 'tailscale_[0-9.]*_arm\.tgz' |
sed -E 's/^tailscale_([0-9.]+)_arm\.tgz$/\1/' |
sort -V | tail -1
+12 -7
View File
@@ -74,24 +74,29 @@ TAILSCALED_PID=$!
sleep 2
TAILSCALE_CMD="$TAILSCALE_PATH --socket=$SOCKET_PATH up --reset --hostname=$(hostname)"
# Arguments for `tailscale up`, built as a list so no setting is ever re-parsed
# by the shell.
set -- --socket="$SOCKET_PATH" up --reset --hostname="$(hostname)"
if [ -n "$CUSTOM_SERVER" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --login-server $CUSTOM_SERVER"
set -- "$@" --login-server "$CUSTOM_SERVER"
fi
if [ -n "$AUTH_KEY" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --authkey $AUTH_KEY"
set -- "$@" --authkey "$AUTH_KEY"
fi
if [ "$ACCEPT_DNS" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-dns=true"
set -- "$@" --accept-dns=true
fi
if [ "$ACCEPT_ROUTES" = "true" ]; then
TAILSCALE_CMD="$TAILSCALE_CMD --accept-routes=true"
set -- "$@" --accept-routes=true
fi
# "192.168.1.0/24, 10.0.0.0/24" is a natural way to type the list.
ADVERTISE_ROUTES=$(printf '%s' "$ADVERTISE_ROUTES" | tr -d ' \t\r\n')
# Advertise LAN subnets so this camera acts as a subnet router. Comma-separated
# CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8). In userspace-networking mode the
# tailscaled netstack forwards tailnet traffic to these subnets, so no kernel IP
@@ -103,7 +108,7 @@ 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
fi
TAILSCALE_CMD="$TAILSCALE_CMD --advertise-routes=$ADVERTISE_ROUTES"
set -- "$@" --advertise-routes="$ADVERTISE_ROUTES"
fi
# Run `tailscale up` in the background and act on its outcome. If the node needs
@@ -115,7 +120,7 @@ fi
# 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"
"$TAILSCALE_PATH" "$@"
up_exit=$?
if [ "$up_exit" -eq 0 ]; then
if [ "$VARIANT" = "root" ]; then
+20 -44
View File
@@ -351,14 +351,12 @@
</button>
</div>
<!-- Status -->
<div id="status-banner" class="status-banner connecting">
<span class="dot"></span>
<span id="status-text" class="status-text">Checking...</span>
<span id="status-time" class="status-time"></span>
</div>
<!-- Update available -->
<div id="update-banner" class="update-banner">
<div class="update-text">Update available: <strong id="update-version"></strong></div>
<a id="update-link" class="update-btn" href="https://github.com/Mo3he/Axis_Cam_Tailscale/releases/latest" target="_blank" rel="noopener">
@@ -367,7 +365,6 @@
</a>
</div>
<!-- Auth (hidden by default) -->
<div id="auth-block" class="auth-block" style="display:none;">
<p>Authenticate this device to connect to your Tailscale network:</p>
<a id="auth-link" class="auth-btn" href="#" target="_blank">
@@ -377,7 +374,6 @@
<span id="auth-url-text" class="auth-url"></span>
</div>
<!-- Connection Info -->
<div class="card" id="info-card" style="display:none;">
<div class="card-title">Connection Details</div>
<div class="info-grid">
@@ -403,7 +399,7 @@
</div>
</div>
<!-- Proxy Info (hidden on ROOT builds, which have no local proxy) -->
<!-- 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">
@@ -418,7 +414,6 @@
</div>
</div>
<!-- Settings -->
<div class="card">
<div class="card-title">Settings</div>
<div class="settings-form">
@@ -474,7 +469,6 @@
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="log-controls">
<div class="card-title" style="margin-bottom:0;">Service Log</div>
@@ -559,11 +553,11 @@
tsIP = allIPs.length ? allIPs[allIPs.length - 1] : null;
}
// Primary: extract hostname from Axis syslog header (always the real device hostname)
// The syslog header always carries the real device hostname.
var node = null;
var hostLine = txt.match(/\d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2}\s+(\S+)\s+\[/);
if (hostLine) node = hostLine[1];
// Fallback: popBrowserAuthNow/StartLoginInteractiveAs (may contain stale acap-tailscale_vpn)
// Fallback; may contain a stale acap-tailscale_vpn name.
if (!node) {
var nodeMatches = txt.match(/popBrowserAuthNow\("([^"]+)"\)/g);
if (!nodeMatches) nodeMatches = txt.match(/StartLoginInteractiveAs\("([^"]+)"\)/g);
@@ -605,7 +599,7 @@
}
}
// Parse proxy ports from log — use last match so old entries don't win
// Use the last match so older log entries don't win.
var httpPort = null;
var httpProxyMatches = txt.match(/HTTP\/HTTPS proxy: http:\/\/127\.0\.0\.1:(\d+)/g);
if (httpProxyMatches) { var m = httpProxyMatches[httpProxyMatches.length - 1].match(/:(\d+)$/); if (m) httpPort = m[1]; }
@@ -613,7 +607,6 @@
var socksProxyMatches = txt.match(/SOCKS5 proxy:\s+127\.0\.0\.1:(\d+)/g);
if (socksProxyMatches) { var ms = socksProxyMatches[socksProxyMatches.length - 1].match(/:(\d+)$/); if (ms) socksPort = ms[1]; }
// Cache when found, restore from cache when missing
cacheSet('ip', tsIP); cacheSet('node', node); cacheSet('tailnet', tailnet); cacheSet('version', version);
cacheSet('http-port', httpPort); cacheSet('socks-port', socksPort);
tsIP = tsIP || cacheGet('ip');
@@ -627,9 +620,8 @@
var lastState = stateLines.length ? stateLines[stateLines.length - 1] : '';
var isRunning = /-> Running/.test(lastState);
// Fallbacks only when syslog has rotated and no state transitions are visible.
// If we CAN see state lines (e.g. "-> NeedsLogin"), trust them over our own
// "Tailscale VPN is running" message which stays in syslog indefinitely.
// Only when syslog rotated away all state lines: our own "Tailscale VPN
// is running" line lingers in syslog and must not override them.
if (!isRunning && stateLines.length === 0) {
isRunning = /Tailscale VPN is running/.test(txt) ||
/health\(warnable=[^)]+\): ok/.test(txt) ||
@@ -638,12 +630,11 @@
/localapi:/.test(txt);
}
// If an auth URL appears AFTER the last Running state, re-auth is needed
// (handles stale Running entries in syslog after reinstall or token expiry)
// An auth URL after the last Running state means re-auth is needed
// (stale Running entries after reinstall or token expiry).
if (isRunning && latestUrl) {
// Use the LATEST of '-> Running' (tailscaled state) or 'Tailscale VPN is running'
// (our shell log). The shell log is written AFTER auth completes, so it correctly
// post-dates the auth URL when connection succeeds.
// Our shell log line is written after auth completes, so it post-dates
// the auth URL on success.
var lastRunIdx = txt.lastIndexOf('-> Running');
var lastRunningMsgIdx = txt.lastIndexOf('Tailscale VPN is running');
if (lastRunningMsgIdx > lastRunIdx) lastRunIdx = lastRunningMsgIdx;
@@ -707,7 +698,6 @@
auth.style.display = 'none';
}
// Proxy card is always visible — update ports whenever known
if (r.httpPort) document.getElementById('ts-http-proxy').textContent = 'http://127.0.0.1:' + r.httpPort;
if (r.socksPort) document.getElementById('ts-socks-proxy').textContent = '127.0.0.1:' + r.socksPort;
@@ -761,7 +751,6 @@
if (st.Version) result.version = String(st.Version).split('-')[0];
if (bs === 'Running' && self.Online === true) {
// Genuinely connected and reachable on the tailnet
result.state = 'connected';
result.url = null;
result.ip = ip4 || result.ip;
@@ -773,11 +762,8 @@
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Running') {
// Backend running but node not online: either a transient network
// drop (no action needed) or the node was removed/expired and needs
// re-auth. Not connected. Keep any login URL the log parser found
// (status.json's AuthURL lags during the `tailscale up` re-auth
// window) so the login button still appears when re-auth is needed.
// Not online: transient drop or node removed/expired. Keep the log
// parser's URL; status.json's AuthURL lags during re-auth.
result.state = 'connecting';
result.url = st.AuthURL || result.url;
} else if (bs === 'Stopped') {
@@ -801,7 +787,7 @@
var st = arr[1];
var result = parse(txt || '');
if (txt) renderLogs(txt);
// Verify the app is actually running - status.json can be stale if stopped
// status.json goes stale when the app is stopped.
checkAppRunning().then(function(running) {
if (!running) {
result.state = 'disconnected';
@@ -869,7 +855,7 @@
return 0;
}
// Settings — load current param values and save on submit
// Settings
var PARAM_URL = '/axis-cgi/param.cgi';
var serverInput = document.getElementById('input-server');
var authInput = document.getElementById('input-authkey');
@@ -881,17 +867,11 @@
var saveBtn = document.getElementById('save-btn');
var saveStatus = document.getElementById('save-status');
// param.cgi is used when available; on devices that lack it (e.g. some
// recorder/NVR-class devices) we fall back to the app's own endpoint,
// exposed through the manifest reverseProxy mapping at API_URL.
// Fallback for devices without param.cgi (e.g. recorders).
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.
// False on ROOT builds (no local proxy). Also keeps the ports out of saves:
// param.cgi fails the whole update on a name the manifest never registered.
var hasProxyPorts = false;
function toggleProxyUi(visible) {
@@ -915,8 +895,7 @@
var dm = txt.match(/root\.\S+\.AcceptDNS=(.*)/);
var rm = txt.match(/root\.\S+\.AcceptRoutes=(.*)/);
var avm = txt.match(/root\.\S+\.AdvertiseRoutes=(.*)/);
// If none of the expected keys are present the endpoint isn't param.cgi
// (e.g. a generic 404 page); signal the caller to use the fallback.
// No expected keys means this isn't param.cgi output (e.g. a 404 page).
if (!sm && !hm && !km) return false;
if (sm) serverInput.value = sm[1].trim();
if (am) authInput.value = am[1].trim();
@@ -979,8 +958,7 @@
.then(function(r) { return r.ok ? r.text() : Promise.reject(); })
.then(function(txt) {
if (/OK/.test(txt)) {
// The app applies the change and restarts its tunnel itself,
// so no separate control.cgi restart is needed here.
// The app restarts its tunnel itself; no control.cgi call needed.
setStatus('Saved. Restarting...', 'ok');
} else {
setStatus('Error saving settings', 'err');
@@ -1011,15 +989,13 @@
.then(function(txt) {
if (/^OK/.test(txt.trim())) {
setStatus('Saved. Restarting...', 'ok');
// Restart the app so new settings take effect
return fetch('/axis-cgi/applications/control.cgi?action=restart&package=' + APP,
{ method: 'POST', credentials: 'same-origin' });
}
// param.cgi reachable but rejected the update — surface the error.
setStatus('Error: ' + txt.trim(), 'err');
})
.catch(function() {
// param.cgi unavailable (e.g. recorder-class device) — use the fallback.
// param.cgi unavailable (e.g. recorder-class device).
return saveViaFallback(httpPort, socksPort);
})
.then(function() { saveBtn.disabled = false; })
+34 -43
View File
@@ -1,22 +1,10 @@
// Copyright (C) 2024 Mo3he
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* ACAP parameter bridge for Tailscale VPN.
*
* Responsibilities:
* 1. Read Tailscale parameters from the ACAP parameter store (axparameter).
* 2. Write them to CONFIG_FILE so the shell script can source them.
* 3. Launch the shell script (Tailscale_VPN_run) as a child process.
* 4. On any parameter change: rewrite CONFIG_FILE and do a full stop+restart
* of the child so the new config is picked up.
* Rapid changes within 300 ms are coalesced into a single restart.
* 5. Watchdog: if the child exits unexpectedly, restart it.
*
* Shared across the userspace-networking variants (unprivileged 'sdk' ACAP
* user) and the ROOT / kernel-networking variant. Build with -DHAS_PROXY_PORTS
* for the userspace variants, which exposes the HTTP/SOCKS5 proxy port
* parameters; the ROOT variant omits them since it has no local proxy.
/*
* ACAP parameter bridge for Tailscale VPN: mirrors axparameter values into
* CONFIG_FILE, runs Tailscale_VPN_run as a child and restarts it on changes.
* Build with -DHAS_PROXY_PORTS for the userspace variants; ROOT has no proxy.
*/
#include <axsdk/axparameter.h>
@@ -70,10 +58,8 @@ 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. */
/* In-place upgrades don't always register new manifest params (param.cgi then
* 404s); ax_parameter_add fails harmlessly if the param already exists. */
static void ensure_param(AXParameter *handle, const char *name, const char *def) {
GError *err = NULL;
if (!ax_parameter_add(handle, name, def, "string", &err)) {
@@ -127,7 +113,7 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
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 */
/* Legacy AuthKey clear: the run script only exits 0 from its TERM/INT trap. */
if (exit_code == 0 && g_ax_handle && cfg_auth_key && *cfg_auth_key) {
GError *err = NULL;
if (ax_parameter_set(g_ax_handle, "AuthKey", "", TRUE, &err)) {
@@ -147,10 +133,8 @@ static gboolean watchdog_cb(gpointer G_GNUC_UNUSED data) {
/* ── 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. */
/* The run script drops SENTINEL_FILE after `tailscale up` used the auth key.
* The exit-code-0 path in watchdog_cb rarely fires since the child stays up. */
static gboolean authkey_sentinel_cb(gpointer G_GNUC_UNUSED data) {
if (access(SENTINEL_FILE, F_OK) != 0)
return G_SOURCE_CONTINUE;
@@ -199,6 +183,19 @@ static void load_config_cache(AXParameter *handle) {
#undef LOAD
}
/* The run script sources this file, so every value must be a single-quoted
* shell literal or it would be executed. */
static void write_var(FILE *f, const char *name, const char *value) {
fprintf(f, "%s='", name);
for (const char *p = value; *p; p++) {
if (*p == '\'')
fputs("'\\''", f);
else
fputc(*p, f);
}
fputs("'\n", f);
}
static void write_config_file(void) {
FILE *f = fopen(CONFIG_FILE, "w");
if (!f) {
@@ -206,15 +203,15 @@ static void write_config_file(void) {
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, ""));
write_var(f, "CUSTOM_SERVER", cache_get(&cfg_custom_server, ""));
write_var(f, "AUTH_KEY", 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"));
write_var(f, "CONF_HTTP", cache_get(&cfg_http_proxy_port, "8080"));
write_var(f, "CONF_SOCKS", 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, ""));
write_var(f, "ACCEPT_DNS", cache_get(&cfg_accept_dns, "false"));
write_var(f, "ACCEPT_ROUTES", cache_get(&cfg_accept_routes, "false"));
write_var(f, "ADVERTISE_ROUTES", cache_get(&cfg_advertise_routes, ""));
fclose(f);
chmod(CONFIG_FILE, 0600);
#ifdef HAS_PROXY_PORTS
@@ -264,11 +261,8 @@ static void parameter_changed(const gchar *name, const gchar *value,
}
/* ── 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. */
* For devices without /axis-cgi/param.cgi (e.g. recorders); reached via the
* manifest reverseProxy at /local/Tailscale_VPN/api/settings. */
#define HTTP_PORT 2201
@@ -331,9 +325,8 @@ static gchar *http_build_settings_json(AXParameter *handle) {
g_free(val);
}
g_string_append_c(out, '}');
/* g_string_free(out, FALSE) is inlined by glib >= 2.76 headers into a call
* to g_string_free_and_steal(), which doesn't exist in older glib runtimes
* (e.g. AXIS OS 11.x). Copy out and fully free instead to stay portable. */
/* Not g_string_free(out, FALSE): glib >= 2.76 headers turn it into
* g_string_free_and_steal(), missing from older runtimes (AXIS OS 11.x). */
gchar *json_result = g_strdup(out->str);
g_string_free(out, TRUE);
return json_result;
@@ -531,11 +524,9 @@ int main(void) {
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. */
/* A stale sentinel would clear a freshly configured key before use. */
unlink(SENTINEL_FILE);
AXParameter *handle = ax_parameter_new(APP_NAME, &error);
-10
View File
@@ -346,7 +346,6 @@
</div>
</nav>
<!-- Hero -->
<section class="hero">
<div class="badge">Open Source &middot; ACAP Package</div>
<h1>Tailscale VPN for<br>Axis <span class="hero-word" id="heroWord">Cameras</span></h1>
@@ -363,7 +362,6 @@
</div>
</section>
<!-- Disclaimer -->
<div style="max-width:720px;margin:0 auto;padding:0 1.5rem 1.5rem;">
<div style="background:rgba(79,143,247,0.07);border:1px solid rgba(79,143,247,0.18);border-radius:10px;padding:12px 18px;font-size:12.5px;color:var(--muted);line-height:1.6;">
<strong style="color:var(--text);">Disclaimer:</strong>
@@ -373,7 +371,6 @@
</div>
</div>
<!-- Features -->
<section id="features" class="features">
<div class="feature-card">
<div class="icon">🔒</div>
@@ -407,12 +404,10 @@
</div>
</section>
<!-- Downloads -->
<section id="downloads" class="downloads">
<h2>Download</h2>
<p class="subtitle">Pick the right variant for your device and Axis OS version.</p>
<div class="download-grid">
<!-- Standard -->
<div class="download-card">
<div class="tag tag-recommended">Recommended</div>
<h3>Standard (Non-Root)</h3>
@@ -428,7 +423,6 @@
</a>
</div>
</div>
<!-- ROOT -->
<div class="download-card">
<div class="tag tag-root">Root &middot; Legacy</div>
<h3>Root (Full Networking)</h3>
@@ -444,7 +438,6 @@
</a>
</div>
</div>
<!-- ACAP3 -->
<div class="download-card">
<div class="tag tag-acap3">ACAP3 &middot; Legacy</div>
<h3>ACAP3 (Older Axis OS)</h3>
@@ -459,7 +452,6 @@
</div>
</section>
<!-- Install -->
<section id="install" class="install">
<h2>Installation</h2>
<div class="steps">
@@ -487,7 +479,6 @@
</div>
</section>
<!-- Footer -->
<footer>
<div class="footer-links">
<a href="https://github.com/Mo3he/Axis_Cam_Tailscale" target="_blank" rel="noopener">GitHub</a>
@@ -540,7 +531,6 @@
}
});
// Rotate hero word
var heroWord = document.getElementById('heroWord');
var devices = ['Cameras', 'Door Stations', 'Intercoms', 'Speakers', 'Radars', 'Encoders'];
var wordIdx = 0;
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"dependencyDashboard": true,
"prConcurrentLimit": 3,
"prHourlyLimit": 0,
"schedule": ["before 5am on Monday"],
"packageRules": [
{
"description": "An SDK bump raises the package's minimum AXIS OS, so decide it deliberately.",
"matchDatasources": ["docker"],
"matchPackageNames": [
"axisecp/acap-native-sdk",
"axisecp/acap-sdk",
"docker.io/axisecp/acap-native-sdk",
"docker.io/axisecp/acap-sdk"
],
"dependencyDashboardApproval": true
},
{
"description": "Transitive Go modules are off by default; enable so CVE fixes can land.",
"matchManagers": ["gomod"],
"matchDepTypes": ["indirect"],
"enabled": true,
"groupName": "Go indirect dependencies",
"groupSlug": "go-indirect-dependencies"
}
]
}